42 files changed
+3330
-171
No files matched your search
@@ -58,12 +58,26 @@ wait_for_release_id() {
|
||||
|
||||
local i
|
||||
local release_id
|
||||
local release_api_url
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
release_id="$(gh api "repos/$repo/releases/tags/$tag" --jq '.id' 2>/dev/null || true)"
|
||||
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
|
||||
echo "$release_id"
|
||||
return 0
|
||||
fi
|
||||
|
||||
release_id="$(gh release view "$tag" --repo "$repo" --json databaseId --jq '.databaseId // empty' 2>/dev/null || true)"
|
||||
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
|
||||
echo "$release_id"
|
||||
return 0
|
||||
fi
|
||||
|
||||
release_api_url="$(gh release view "$tag" --repo "$repo" --json apiUrl --jq '.apiUrl // empty' 2>/dev/null || true)"
|
||||
if [[ "$release_api_url" =~ /releases/([0-9]+)$ ]]; then
|
||||
echo "${BASH_REMATCH[1]}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Release id for tag '$tag' is not ready yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
|
||||
sleep "$delay_seconds"
|
||||
@@ -71,6 +85,7 @@ wait_for_release_id() {
|
||||
done
|
||||
|
||||
echo "Unable to fetch release id for tag '$tag' after $attempts attempts." >&2
|
||||
gh release view "$tag" --repo "$repo" --json databaseId,id,isDraft,isPrerelease,url 2>/dev/null || true
|
||||
gh api "repos/$repo/releases/tags/$tag" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
@@ -87,9 +102,10 @@ settle_release_state() {
|
||||
local draft_state
|
||||
local prerelease_state
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
gh release edit "$tag" --repo "$repo" --draft=false --prerelease >/dev/null 2>&1 || true
|
||||
gh api --method PATCH "repos/$repo/releases/$release_id" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||
draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || echo true)"
|
||||
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||
draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || gh release view "$tag" --repo "$repo" --json isDraft --jq '.isDraft' 2>/dev/null || echo true)"
|
||||
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || gh release view "$tag" --repo "$repo" --json isPrerelease --jq '.isPrerelease' 2>/dev/null || echo false)"
|
||||
if [ "$draft_state" = "false" ] && [ "$prerelease_state" = "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
@@ -100,10 +116,19 @@ settle_release_state() {
|
||||
done
|
||||
|
||||
echo "Failed to settle release state for tag '$tag'." >&2
|
||||
gh release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url 2>/dev/null || true
|
||||
gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
print_release_state() {
|
||||
local repo="$1"
|
||||
local tag="$2"
|
||||
|
||||
gh api "repos/$repo/releases/tags/$tag" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}' 2>/dev/null \
|
||||
|| gh release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url --jq '{isDraft: .isDraft, isPrerelease: .isPrerelease, url: .url}'
|
||||
}
|
||||
|
||||
wait_for_release_absent() {
|
||||
local repo="$1"
|
||||
local tag="$2"
|
||||
|
||||
@@ -287,6 +287,12 @@ jobs:
|
||||
if: always() && needs.prepare.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Update fixed dev release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -380,4 +386,4 @@ jobs:
|
||||
source .github/scripts/release-utils.sh
|
||||
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
|
||||
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
|
||||
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||
print_release_state "$REPO" "$TAG"
|
||||
@@ -328,6 +328,12 @@ jobs:
|
||||
if: needs.prepare.outputs.should_build == 'true' && always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Update preview release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -423,4 +429,4 @@ jobs:
|
||||
source .github/scripts/release-utils.sh
|
||||
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
|
||||
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
|
||||
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||
print_release_state "$REPO" "$TAG"
|
||||
@@ -252,6 +252,11 @@ jobs:
|
||||
- release-windows-arm64
|
||||
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Generate release notes with platform download links
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -345,7 +350,6 @@ jobs:
|
||||
updpkgsums: true
|
||||
assets: |
|
||||
resources/installer/linux/weflow.desktop
|
||||
resources/installer/linux/icon.png
|
||||
|
||||
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
commit_username: H3CoF6
|
||||
|
||||
+2
-1
@@ -76,4 +76,5 @@ wechat-research-site
|
||||
.codex
|
||||
weflow-web-offical
|
||||
/Wedecrypt
|
||||
/scripts/syncwcdb.py
|
||||
/scripts/syncwcdb.py
|
||||
/scripts/syncWedecrypt.py
|
||||
+1
-1
@@ -194,7 +194,7 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
|
||||
"messages": [
|
||||
{
|
||||
"localId": 123,
|
||||
"serverId": "456",
|
||||
"serverId": "6116895530414915131",
|
||||
"localType": 1,
|
||||
"createTime": 1738713600,
|
||||
"isSend": 0,
|
||||
|
||||
@@ -5,6 +5,7 @@ interface ExportWorkerConfig {
|
||||
sessionIds: string[]
|
||||
outputDir: string
|
||||
options: ExportOptions
|
||||
taskId?: string
|
||||
dbPath?: string
|
||||
decryptKey?: string
|
||||
myWxid?: string
|
||||
@@ -14,6 +15,27 @@ interface ExportWorkerConfig {
|
||||
}
|
||||
|
||||
const config = workerData as ExportWorkerConfig
|
||||
const controlState = {
|
||||
pauseRequested: false,
|
||||
stopRequested: false
|
||||
}
|
||||
|
||||
parentPort?.on('message', (message: any) => {
|
||||
if (!message || typeof message.type !== 'string') return
|
||||
if (message.type === 'export:pause') {
|
||||
controlState.pauseRequested = true
|
||||
return
|
||||
}
|
||||
if (message.type === 'export:resume') {
|
||||
controlState.pauseRequested = false
|
||||
return
|
||||
}
|
||||
if (message.type === 'export:cancel') {
|
||||
controlState.stopRequested = true
|
||||
controlState.pauseRequested = false
|
||||
}
|
||||
})
|
||||
|
||||
process.env.WEFLOW_WORKER = '1'
|
||||
if (config.resourcesPath) {
|
||||
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
||||
@@ -47,7 +69,19 @@ async function run() {
|
||||
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
|
||||
)
|
||||
|
||||
parentPort?.postMessage({
|
||||
|
||||
+159
-33
@@ -16,6 +16,7 @@ import { analyticsService } from './services/analyticsService'
|
||||
import { groupAnalyticsService } from './services/groupAnalyticsService'
|
||||
import { annualReportService } from './services/annualReportService'
|
||||
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
||||
import { exportTaskControlService } from './services/exportTaskControlService'
|
||||
import { KeyService } from './services/keyService'
|
||||
import { KeyServiceLinux } from './services/keyServiceLinux'
|
||||
import { KeyServiceMac } from './services/keyServiceMac'
|
||||
@@ -33,6 +34,7 @@ import { messagePushService } from './services/messagePushService'
|
||||
import { insightService } from './services/insightService'
|
||||
import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService'
|
||||
import { bizService } from './services/bizService'
|
||||
import { backupService } from './services/backupService'
|
||||
|
||||
// 配置自动更新
|
||||
autoUpdater.autoDownload = false
|
||||
@@ -63,6 +65,42 @@ const defaultUpdateTrack: 'stable' | 'preview' | 'dev' = (() => {
|
||||
return 'stable'
|
||||
})()
|
||||
let configService: ConfigService | null = null
|
||||
const activeExportWorkers = new Map<string, Worker>()
|
||||
const activeExportTasks = new Set<string>()
|
||||
|
||||
const normalizeExportTaskId = (taskId: unknown): string => String(taskId || '').trim()
|
||||
|
||||
const postExportWorkerControl = (taskId: string, action: 'pause' | 'resume' | 'cancel') => {
|
||||
const worker = activeExportWorkers.get(taskId)
|
||||
if (!worker) return
|
||||
try {
|
||||
worker.postMessage({ type: `export:${action}` })
|
||||
} catch (error) {
|
||||
console.warn(`[export-task-control] failed to post ${action} to worker:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
const finalizeExportTaskControlResult = async (taskId: string, result: any) => {
|
||||
if (!taskId) return result
|
||||
if (result?.stopped) {
|
||||
const cleanup = await exportTaskControlService.cleanupTask(taskId)
|
||||
if (!cleanup.success) {
|
||||
return {
|
||||
...result,
|
||||
success: false,
|
||||
error: `导出已停止,但清理已导出文件失败:${cleanup.error || '未知错误'}`
|
||||
}
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
if (!result?.paused) {
|
||||
exportTaskControlService.releaseTask(taskId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const normalizeUpdateTrack = (raw: unknown): 'stable' | 'preview' | 'dev' | null => {
|
||||
if (raw === 'stable' || raw === 'preview' || raw === 'dev') return raw
|
||||
@@ -747,6 +785,10 @@ const getWindowCloseBehavior = (): WindowCloseBehavior => {
|
||||
return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask'
|
||||
}
|
||||
|
||||
const isSilentStartupEnabled = (): boolean => {
|
||||
return configService?.get('silentStartup') === true
|
||||
}
|
||||
|
||||
const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => {
|
||||
if (isClosePromptVisible) return
|
||||
isClosePromptVisible = true
|
||||
@@ -2178,6 +2220,18 @@ function registerIpcHandlers() {
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('backup:create', async (_, payload: { outputPath: string; options?: { includeImages?: boolean; includeVideos?: boolean; includeFiles?: boolean } }) => {
|
||||
return backupService.createBackup(payload.outputPath, payload.options)
|
||||
})
|
||||
|
||||
ipcMain.handle('backup:inspect', async (_, payload: { archivePath: string }) => {
|
||||
return backupService.inspectBackup(payload.archivePath)
|
||||
})
|
||||
|
||||
ipcMain.handle('backup:restore', async (_, payload: { archivePath: string }) => {
|
||||
return backupService.restoreBackup(payload.archivePath)
|
||||
})
|
||||
|
||||
|
||||
|
||||
// 聊天相关
|
||||
@@ -2224,6 +2278,10 @@ function registerIpcHandlers() {
|
||||
return chatService.getNewMessages(sessionId, minTime, limit)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getAntiRevokeSessions', async () => {
|
||||
return chatService.getAntiRevokeSessions()
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:updateMessage', async (_, sessionId: string, localId: number, createTime: number, newContent: string) => {
|
||||
return chatService.updateMessage(sessionId, localId, createTime, newContent)
|
||||
})
|
||||
@@ -2615,16 +2673,25 @@ function registerIpcHandlers() {
|
||||
|
||||
ipcMain.handle('sns:exportTimeline', async (event, options: any) => {
|
||||
const exportOptions = { ...(options || {}) }
|
||||
const taskId = normalizeExportTaskId(exportOptions.taskId)
|
||||
delete exportOptions.taskId
|
||||
const taskControl = taskId ? exportTaskControlService.createControl(taskId, String(exportOptions.outputDir || '')) : undefined
|
||||
if (taskId) activeExportTasks.add(taskId)
|
||||
|
||||
return snsService.exportTimeline(
|
||||
exportOptions,
|
||||
(progress) => {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('sns:exportProgress', progress)
|
||||
}
|
||||
}
|
||||
)
|
||||
try {
|
||||
const result = await snsService.exportTimeline(
|
||||
exportOptions,
|
||||
(progress) => {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('sns:exportProgress', progress)
|
||||
}
|
||||
},
|
||||
taskControl
|
||||
)
|
||||
return finalizeExportTaskControlResult(taskId, result)
|
||||
} finally {
|
||||
if (taskId) activeExportTasks.delete(taskId)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:selectExportDir', async () => {
|
||||
@@ -2947,7 +3014,40 @@ function registerIpcHandlers() {
|
||||
return exportService.getExportStats(sessionIds, options)
|
||||
})
|
||||
|
||||
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||
ipcMain.handle('export:pauseTask', async (_, taskId: string) => {
|
||||
const normalizedTaskId = normalizeExportTaskId(taskId)
|
||||
if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' }
|
||||
const success = exportTaskControlService.pauseTask(normalizedTaskId)
|
||||
if (success) postExportWorkerControl(normalizedTaskId, 'pause')
|
||||
return { success }
|
||||
})
|
||||
|
||||
ipcMain.handle('export:resumeTask', async (_, taskId: string) => {
|
||||
const normalizedTaskId = normalizeExportTaskId(taskId)
|
||||
if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' }
|
||||
const success = exportTaskControlService.resumeTask(normalizedTaskId)
|
||||
if (success) postExportWorkerControl(normalizedTaskId, 'resume')
|
||||
return { success }
|
||||
})
|
||||
|
||||
ipcMain.handle('export:cancelTask', async (_, taskId: string) => {
|
||||
const normalizedTaskId = normalizeExportTaskId(taskId)
|
||||
if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' }
|
||||
const success = exportTaskControlService.cancelTask(normalizedTaskId)
|
||||
if (success) postExportWorkerControl(normalizedTaskId, 'cancel')
|
||||
if (success && !activeExportTasks.has(normalizedTaskId)) {
|
||||
const cleanup = await exportTaskControlService.cleanupTask(normalizedTaskId)
|
||||
return cleanup.success
|
||||
? { success: true, cleanup }
|
||||
: { success: false, error: cleanup.error || '清理已导出文件失败' }
|
||||
}
|
||||
return { success }
|
||||
})
|
||||
|
||||
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) activeExportTasks.add(taskId)
|
||||
const PROGRESS_FORWARD_INTERVAL_MS = 180
|
||||
let pendingProgress: ExportProgress | null = null
|
||||
let progressTimer: NodeJS.Timeout | null = null
|
||||
@@ -2993,7 +3093,7 @@ function registerIpcHandlers() {
|
||||
|
||||
const runMainFallback = async (reason: string) => {
|
||||
console.warn(`[fallback-export-main] ${reason}`)
|
||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
|
||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress, taskControl)
|
||||
}
|
||||
|
||||
const cfg = configService || new ConfigService()
|
||||
@@ -3015,6 +3115,7 @@ function registerIpcHandlers() {
|
||||
sessionIds,
|
||||
outputDir,
|
||||
options,
|
||||
taskId,
|
||||
dbPath,
|
||||
decryptKey,
|
||||
myWxid,
|
||||
@@ -3025,9 +3126,15 @@ function registerIpcHandlers() {
|
||||
})
|
||||
|
||||
let settled = false
|
||||
if (taskId) {
|
||||
activeExportWorkers.set(taskId, worker)
|
||||
}
|
||||
const finalizeResolve = (value: any) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (taskId && activeExportWorkers.get(taskId) === worker) {
|
||||
activeExportWorkers.delete(taskId)
|
||||
}
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate()
|
||||
resolve(value)
|
||||
@@ -3035,6 +3142,9 @@ function registerIpcHandlers() {
|
||||
const finalizeReject = (error: Error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (taskId && activeExportWorkers.get(taskId) === worker) {
|
||||
activeExportWorkers.delete(taskId)
|
||||
}
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate()
|
||||
reject(error)
|
||||
@@ -3045,6 +3155,14 @@ function registerIpcHandlers() {
|
||||
onProgress(msg.data as ExportProgress)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:createdFile' && taskId) {
|
||||
exportTaskControlService.recordCreatedFile(taskId, String(msg.filePath || ''))
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:createdDir' && taskId) {
|
||||
exportTaskControlService.recordCreatedDir(taskId, String(msg.dirPath || ''))
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:result') {
|
||||
finalizeResolve(msg.data)
|
||||
return
|
||||
@@ -3070,10 +3188,13 @@ function registerIpcHandlers() {
|
||||
}
|
||||
|
||||
try {
|
||||
return await runWorker()
|
||||
const result = await runWorker()
|
||||
return await finalizeExportTaskControlResult(taskId, result)
|
||||
} catch (error) {
|
||||
return runMainFallback(error instanceof Error ? error.message : String(error))
|
||||
const result = await runMainFallback(error instanceof Error ? error.message : String(error))
|
||||
return await finalizeExportTaskControlResult(taskId, result)
|
||||
} finally {
|
||||
if (taskId) activeExportTasks.delete(taskId)
|
||||
flushProgress()
|
||||
if (progressTimer) {
|
||||
clearTimeout(progressTimer)
|
||||
@@ -3727,21 +3848,31 @@ function checkForUpdatesOnStartup() {
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// 立即创建 Splash 窗口,确保用户尽快看到反馈
|
||||
createSplashWindow()
|
||||
// 先初始化配置,以便在启动早期判定是否需要静默启动
|
||||
configService = new ConfigService()
|
||||
applyAutoUpdateChannel('startup')
|
||||
syncLaunchAtStartupPreference()
|
||||
const onboardingDone = configService.get('onboardingDone') === true
|
||||
const startInBackground = onboardingDone && isSilentStartupEnabled()
|
||||
shouldShowMain = onboardingDone
|
||||
|
||||
// 等待 Splash 页面加载完成后再推送进度
|
||||
if (splashWindow) {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (splashWindow!.webContents.isLoading()) {
|
||||
splashWindow!.webContents.once('did-finish-load', () => resolve())
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
splashWindow.webContents
|
||||
.executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
|
||||
.catch(() => {})
|
||||
if (!startInBackground) {
|
||||
// 非静默模式下显示 Splash,提供启动反馈
|
||||
createSplashWindow()
|
||||
|
||||
// 等待 Splash 页面加载完成后再推送进度
|
||||
if (splashWindow) {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (splashWindow!.webContents.isLoading()) {
|
||||
splashWindow!.webContents.once('did-finish-load', () => resolve())
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
splashWindow.webContents
|
||||
.executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
@@ -3770,13 +3901,7 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化配置服务
|
||||
updateSplashProgress(5, '正在加载配置...')
|
||||
configService = new ConfigService()
|
||||
applyAutoUpdateChannel('startup')
|
||||
syncLaunchAtStartupPreference()
|
||||
const onboardingDone = configService.get('onboardingDone') === true
|
||||
shouldShowMain = onboardingDone
|
||||
|
||||
// 将用户主题配置推送给 Splash 窗口
|
||||
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||
@@ -3943,6 +4068,8 @@ app.whenReady().then(async () => {
|
||||
|
||||
if (!onboardingDone) {
|
||||
createOnboardingWindow()
|
||||
} else if (startInBackground && tray) {
|
||||
mainWindow?.hide()
|
||||
} else {
|
||||
mainWindow?.show()
|
||||
}
|
||||
@@ -3996,4 +4123,3 @@ app.on('window-all-closed', () => {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+20
-2
@@ -154,6 +154,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
},
|
||||
|
||||
backup: {
|
||||
create: (payload: { outputPath: string; options?: { includeImages?: boolean; includeVideos?: boolean; includeFiles?: boolean } }) => ipcRenderer.invoke('backup:create', payload),
|
||||
inspect: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:inspect', payload),
|
||||
restore: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:restore', payload),
|
||||
onProgress: (callback: (progress: any) => void) => {
|
||||
const listener = (_: unknown, progress: any) => callback(progress)
|
||||
ipcRenderer.on('backup:progress', listener)
|
||||
return () => ipcRenderer.removeListener('backup:progress', listener)
|
||||
}
|
||||
},
|
||||
|
||||
// 密钥获取
|
||||
key: {
|
||||
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
||||
@@ -174,6 +185,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
chat: {
|
||||
connect: () => ipcRenderer.invoke('chat:connect'),
|
||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||
getAntiRevokeSessions: () => ipcRenderer.invoke('chat:getAntiRevokeSessions'),
|
||||
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
|
||||
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
|
||||
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
|
||||
@@ -451,8 +463,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
export: {
|
||||
getExportStats: (sessionIds: string[], options: any) =>
|
||||
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
|
||||
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||
exportSessions: (sessionIds: string[], outputDir: string, options: any, controlOptions?: { taskId?: string }) =>
|
||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options, controlOptions),
|
||||
pauseTask: (taskId: string) =>
|
||||
ipcRenderer.invoke('export:pauseTask', taskId),
|
||||
resumeTask: (taskId: string) =>
|
||||
ipcRenderer.invoke('export:resumeTask', taskId),
|
||||
cancelTask: (taskId: string) =>
|
||||
ipcRenderer.invoke('export:cancelTask', taskId),
|
||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||
exportContacts: (outputDir: string, options: any) =>
|
||||
|
||||
@@ -0,0 +1,1084 @@
|
||||
import { BrowserWindow, app } from 'electron'
|
||||
import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'fs'
|
||||
import { copyFile, link, readFile as readFileAsync, mkdtemp, writeFile } from 'fs/promises'
|
||||
import { basename, dirname, join, relative, resolve, sep } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import * as tar from 'tar'
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { expandHomePath } from '../utils/pathUtils'
|
||||
|
||||
type BackupDbKind = 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns' | 'hardlink'
|
||||
type BackupPhase = 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
|
||||
type BackupResourceKind = 'image' | 'video' | 'file'
|
||||
const TEMP_MARKER = '.weflow-backup-temp'
|
||||
const TEMP_TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export interface BackupOptions {
|
||||
includeImages?: boolean
|
||||
includeVideos?: boolean
|
||||
includeFiles?: boolean
|
||||
}
|
||||
|
||||
interface BackupDbEntry {
|
||||
id: string
|
||||
kind: BackupDbKind
|
||||
dbPath: string
|
||||
relativePath: string
|
||||
tables: BackupTableEntry[]
|
||||
}
|
||||
|
||||
interface BackupTableEntry {
|
||||
name: string
|
||||
snapshotPath: string
|
||||
rows: number
|
||||
columns: number
|
||||
schemaSql?: string
|
||||
}
|
||||
|
||||
interface BackupResourceEntry {
|
||||
kind: BackupResourceKind
|
||||
id: string
|
||||
md5?: string
|
||||
sessionId?: string
|
||||
createTime?: number
|
||||
sourceFileName?: string
|
||||
archivePath: string
|
||||
targetRelativePath: string
|
||||
ext?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface BackupManifest {
|
||||
version: 1
|
||||
type: 'weflow-db-snapshots'
|
||||
createdAt: string
|
||||
appVersion: string
|
||||
source: {
|
||||
wxid: string
|
||||
dbRoot: string
|
||||
}
|
||||
databases: BackupDbEntry[]
|
||||
options?: BackupOptions
|
||||
resources?: {
|
||||
images?: BackupResourceEntry[]
|
||||
videos?: BackupResourceEntry[]
|
||||
files?: BackupResourceEntry[]
|
||||
}
|
||||
}
|
||||
|
||||
interface BackupProgress {
|
||||
phase: BackupPhase
|
||||
message: string
|
||||
current?: number
|
||||
total?: number
|
||||
detail?: string
|
||||
}
|
||||
|
||||
function emitBackupProgress(progress: BackupProgress): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('backup:progress', progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeName(value: string): string {
|
||||
return encodeURIComponent(value || 'unnamed').replace(/%/g, '_')
|
||||
}
|
||||
|
||||
function toArchivePath(path: string): string {
|
||||
return path.split(sep).join('/')
|
||||
}
|
||||
|
||||
async function withTimeout<T>(task: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
try {
|
||||
return await Promise.race([
|
||||
task,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms = 0): Promise<void> {
|
||||
return new Promise(resolveDelay => setTimeout(resolveDelay, ms))
|
||||
}
|
||||
|
||||
function createThrottledProgressEmitter(minIntervalMs = 120): (progress: BackupProgress, force?: boolean) => void {
|
||||
let lastEmitAt = 0
|
||||
return (progress: BackupProgress, force = false) => {
|
||||
const now = Date.now()
|
||||
if (!force && now - lastEmitAt < minIntervalMs) return
|
||||
lastEmitAt = now
|
||||
emitBackupProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<void>
|
||||
): Promise<void> {
|
||||
let nextIndex = 0
|
||||
const workerCount = Math.max(1, Math.min(concurrency, items.length))
|
||||
await Promise.all(Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const index = nextIndex
|
||||
nextIndex += 1
|
||||
if (index >= items.length) return
|
||||
await worker(items[index], index)
|
||||
if (index % 50 === 0) await delay()
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function hasResourceOptions(options: BackupOptions): boolean {
|
||||
return options.includeImages === true || options.includeVideos === true || options.includeFiles === true
|
||||
}
|
||||
|
||||
function normalizeArchivePath(value: string): string {
|
||||
return String(value || '').replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
export class BackupService {
|
||||
private configService = new ConfigService()
|
||||
private cleanedTempDirs = false
|
||||
|
||||
private cleanupStaleTempDirs(): void {
|
||||
if (this.cleanedTempDirs) return
|
||||
this.cleanedTempDirs = true
|
||||
const root = tmpdir()
|
||||
const now = Date.now()
|
||||
try {
|
||||
for (const entry of readdirSync(root)) {
|
||||
if (!entry.startsWith('weflow-backup-')) continue
|
||||
const dir = join(root, entry)
|
||||
const marker = join(dir, TEMP_MARKER)
|
||||
try {
|
||||
const stat = statSync(dir)
|
||||
if (!stat.isDirectory()) continue
|
||||
if (!existsSync(marker)) continue
|
||||
const age = now - stat.mtimeMs
|
||||
if (age < TEMP_TTL_MS) continue
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private async createTempDir(prefix: string): Promise<string> {
|
||||
this.cleanupStaleTempDirs()
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix))
|
||||
await writeFile(join(dir, TEMP_MARKER), String(Date.now()), 'utf8')
|
||||
return dir
|
||||
}
|
||||
|
||||
private buildWxidCandidates(wxid: string): string[] {
|
||||
const wxidCandidates = Array.from(new Set([
|
||||
String(wxid || '').trim(),
|
||||
this.cleanAccountDirName(wxid)
|
||||
].filter(Boolean)))
|
||||
return wxidCandidates
|
||||
}
|
||||
|
||||
private isCurrentAccountDir(accountDir: string, wxidCandidates: string[]): boolean {
|
||||
const accountName = basename(accountDir).toLowerCase()
|
||||
return wxidCandidates
|
||||
.map(item => item.toLowerCase())
|
||||
.some(wxid => accountName === wxid || accountName.startsWith(`${wxid}_`))
|
||||
}
|
||||
|
||||
private normalizeExistingPath(inputPath: string): string {
|
||||
const expanded = expandHomePath(String(inputPath || '').trim()).replace(/[\\/]+$/, '')
|
||||
if (!expanded) return expanded
|
||||
try {
|
||||
if (existsSync(expanded) && statSync(expanded).isFile()) {
|
||||
return dirname(expanded)
|
||||
}
|
||||
} catch {}
|
||||
return expanded
|
||||
}
|
||||
|
||||
private resolveAncestorDbStorage(normalized: string, wxidCandidates: string[]): string | null {
|
||||
let current = normalized
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
if (!current) break
|
||||
if (basename(current).toLowerCase() === 'db_storage') {
|
||||
const accountDir = dirname(current)
|
||||
if (this.isCurrentAccountDir(accountDir, wxidCandidates) && existsSync(current)) {
|
||||
return current
|
||||
}
|
||||
}
|
||||
const parent = dirname(current)
|
||||
if (!parent || parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveCurrentAccountDbStorageFromRoot(rootPath: string, wxidCandidates: string[]): string | null {
|
||||
if (!rootPath || !existsSync(rootPath)) return null
|
||||
|
||||
for (const candidateWxid of wxidCandidates) {
|
||||
const viaWxid = join(rootPath, candidateWxid, 'db_storage')
|
||||
if (existsSync(viaWxid)) return viaWxid
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(rootPath)
|
||||
const loweredWxids = wxidCandidates.map(item => item.toLowerCase())
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(rootPath, entry)
|
||||
try {
|
||||
if (!statSync(entryPath).isDirectory()) continue
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const lowerEntry = entry.toLowerCase()
|
||||
if (!loweredWxids.some(id => lowerEntry === id || lowerEntry.startsWith(`${id}_`))) continue
|
||||
const candidate = join(entryPath, 'db_storage')
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveDbStoragePath(dbPath: string, wxid: string): string | null {
|
||||
const normalized = this.normalizeExistingPath(dbPath)
|
||||
if (!normalized) return null
|
||||
|
||||
const wxidCandidates = this.buildWxidCandidates(wxid)
|
||||
const ancestor = this.resolveAncestorDbStorage(normalized, wxidCandidates)
|
||||
if (ancestor) return ancestor
|
||||
|
||||
const direct = join(normalized, 'db_storage')
|
||||
if (existsSync(direct) && this.isCurrentAccountDir(normalized, wxidCandidates)) return direct
|
||||
|
||||
const roots = Array.from(new Set([
|
||||
normalized,
|
||||
join(normalized, 'WeChat Files'),
|
||||
join(normalized, 'xwechat_files')
|
||||
]))
|
||||
for (const root of roots) {
|
||||
const dbStorage = this.resolveCurrentAccountDbStorageFromRoot(root, wxidCandidates)
|
||||
if (dbStorage) return dbStorage
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
||||
const dbStorage = this.resolveDbStoragePath(dbPath, wxid)
|
||||
return dbStorage ? dirname(dbStorage) : null
|
||||
}
|
||||
|
||||
private cleanAccountDirName(wxid: string): string {
|
||||
const trimmed = String(wxid || '').trim()
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
return match?.[1] || trimmed
|
||||
}
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
private async listFilesForArchive(root: string, rel = '', state = { visited: 0 }): Promise<string[]> {
|
||||
const dir = join(root, rel)
|
||||
const files: string[] = []
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const entryRel = rel ? join(rel, entry) : entry
|
||||
const entryPath = join(root, entryRel)
|
||||
try {
|
||||
const stat = statSync(entryPath)
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...await this.listFilesForArchive(root, entryRel, state))
|
||||
} else if (stat.isFile()) {
|
||||
files.push(toArchivePath(entryRel))
|
||||
}
|
||||
state.visited += 1
|
||||
if (state.visited % 200 === 0) await delay()
|
||||
} catch {}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
private resolveExtractedPath(extractDir: string, archivePath: string): string | null {
|
||||
const normalized = normalizeArchivePath(archivePath)
|
||||
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
||||
const root = resolve(extractDir)
|
||||
const target = resolve(join(extractDir, normalized))
|
||||
if (target !== root && !target.startsWith(`${root}${sep}`)) return null
|
||||
return target
|
||||
}
|
||||
|
||||
private resolveStagingPath(stagingDir: string, archivePath: string): string | null {
|
||||
return this.resolveExtractedPath(stagingDir, archivePath)
|
||||
}
|
||||
|
||||
private resolveTargetResourcePath(accountDir: string, relativePath: string): string | null {
|
||||
const normalized = normalizeArchivePath(relativePath)
|
||||
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
||||
const root = resolve(accountDir)
|
||||
const target = resolve(join(accountDir, normalized))
|
||||
if (target !== root && !target.startsWith(`${root}${sep}`)) return null
|
||||
return target
|
||||
}
|
||||
|
||||
private isSafeAccountRelativePath(accountDir: string, filePath: string): string | null {
|
||||
const rel = toArchivePath(relative(accountDir, filePath))
|
||||
if (!rel || rel.startsWith('..') || rel.startsWith('/')) return null
|
||||
return rel
|
||||
}
|
||||
|
||||
private async listFilesUnderDir(root: string, state = { visited: 0 }): Promise<string[]> {
|
||||
const files: string[] = []
|
||||
if (!existsSync(root)) return files
|
||||
try {
|
||||
for (const entry of readdirSync(root)) {
|
||||
const fullPath = join(root, entry)
|
||||
let stat
|
||||
try {
|
||||
stat = statSync(fullPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...await this.listFilesUnderDir(fullPath, state))
|
||||
} else if (stat.isFile()) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
state.visited += 1
|
||||
if (state.visited % 300 === 0) await delay()
|
||||
}
|
||||
} catch {}
|
||||
return files
|
||||
}
|
||||
|
||||
private async stagePlainResource(sourcePath: string, outputPath: string): Promise<void> {
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
try {
|
||||
await link(sourcePath, outputPath)
|
||||
} catch {
|
||||
await copyFile(sourcePath, outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
private async writeTarEntryToFile(entry: any, outputPath: string): Promise<void> {
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
const out = createWriteStream(outputPath)
|
||||
const fail = (error: unknown) => rejectPromise(error instanceof Error ? error : new Error(String(error)))
|
||||
out.on('finish', resolvePromise)
|
||||
out.on('error', fail)
|
||||
entry.on('error', fail)
|
||||
entry.pipe(out)
|
||||
})
|
||||
}
|
||||
|
||||
private async listChatImageDatFiles(accountDir: string): Promise<string[]> {
|
||||
const attachRoot = join(accountDir, 'msg', 'attach')
|
||||
const result: string[] = []
|
||||
if (!existsSync(attachRoot)) return result
|
||||
|
||||
const scanImgDir = async (imgDir: string): Promise<void> => {
|
||||
let entries: string[] = []
|
||||
try {
|
||||
entries = readdirSync(imgDir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(imgDir, entry)
|
||||
let stat
|
||||
try {
|
||||
stat = statSync(fullPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (stat.isFile() && entry.toLowerCase().endsWith('.dat')) {
|
||||
result.push(fullPath)
|
||||
} else if (stat.isDirectory()) {
|
||||
let nestedEntries: string[] = []
|
||||
try {
|
||||
nestedEntries = readdirSync(fullPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const nestedEntry of nestedEntries) {
|
||||
const nestedPath = join(fullPath, nestedEntry)
|
||||
try {
|
||||
if (statSync(nestedPath).isFile() && nestedEntry.toLowerCase().endsWith('.dat')) {
|
||||
result.push(nestedPath)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (result.length > 0 && result.length % 500 === 0) await delay()
|
||||
}
|
||||
}
|
||||
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
let entries: Array<{ name: string; isDirectory: () => boolean }> = []
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const child = join(dir, entry.name)
|
||||
if (entry.name.toLowerCase() === 'img') {
|
||||
await scanImgDir(child)
|
||||
} else {
|
||||
await walk(child)
|
||||
}
|
||||
if (result.length > 0 && result.length % 500 === 0) await delay()
|
||||
}
|
||||
}
|
||||
|
||||
await walk(attachRoot)
|
||||
return Array.from(new Set(result))
|
||||
}
|
||||
|
||||
private async ensureConnected(wxidOverride?: string): Promise<{ success: boolean; wxid?: string; dbPath?: string; dbStorage?: string; error?: string }> {
|
||||
const configuredWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const wxid = String(wxidOverride || configuredWxid || '').trim()
|
||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||
const decryptKey = String(this.configService.get('decryptKey') || '').trim()
|
||||
if (!wxid || !dbPath) return { success: false, error: '请先配置数据库路径和微信账号' }
|
||||
if (!decryptKey) return { success: false, error: '请先配置数据库解密密钥' }
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) return { success: false, error: `未在配置的 dbPath 下找到账号目录:${wxid}` }
|
||||
const dbStorage = join(accountDir, 'db_storage')
|
||||
if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' }
|
||||
|
||||
const accountDirName = basename(accountDir)
|
||||
const opened = await withTimeout(
|
||||
wcdbService.open(dbPath, decryptKey, accountDirName),
|
||||
15000,
|
||||
'连接目标账号数据库超时,请检查数据库路径、密钥是否正确'
|
||||
)
|
||||
if (!opened) {
|
||||
const detail = await wcdbService.getLastInitError().catch(() => null)
|
||||
return { success: false, error: detail || `目标账号 ${accountDirName} 数据库连接失败` }
|
||||
}
|
||||
|
||||
return { success: true, wxid: accountDirName, dbPath, dbStorage }
|
||||
}
|
||||
|
||||
private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string {
|
||||
if (kind === 'session' || kind === 'contact' || kind === 'emoticon' || kind === 'sns' || kind === 'hardlink') return kind
|
||||
return `${kind}-${index}-${safeName(basename(dbPath)).slice(0, 80)}`
|
||||
}
|
||||
|
||||
private toDbRelativePath(dbStorage: string, dbPath: string): string {
|
||||
const rel = toArchivePath(relative(dbStorage, dbPath))
|
||||
if (!rel || rel.startsWith('..') || rel.startsWith('/')) return basename(dbPath)
|
||||
return rel
|
||||
}
|
||||
|
||||
private resolveTargetDbPath(dbStorage: string, relativePath: string): string | null {
|
||||
const normalized = String(relativePath || '').replace(/\\/g, '/')
|
||||
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
||||
const root = resolve(dbStorage)
|
||||
const target = resolve(join(dbStorage, normalized))
|
||||
if (target !== root && !target.startsWith(`${root}${sep}`)) return null
|
||||
return target
|
||||
}
|
||||
|
||||
private defaultRelativeDbPath(kind: BackupDbKind): string | null {
|
||||
if (kind === 'session') return 'session/session.db'
|
||||
if (kind === 'contact') return 'contact/contact.db'
|
||||
if (kind === 'emoticon') return 'emoticon/emoticon.db'
|
||||
if (kind === 'sns') return 'sns/sns.db'
|
||||
if (kind === 'hardlink') return 'hardlink/hardlink.db'
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveRestoreTargetDbPath(dbStorage: string, db: BackupDbEntry): string | null {
|
||||
const normalized = String(db.relativePath || '').replace(/\\/g, '/')
|
||||
const legacyFixedPath = this.defaultRelativeDbPath(db.kind)
|
||||
if (legacyFixedPath && (!normalized.includes('/') || !normalized.toLowerCase().endsWith('.db'))) {
|
||||
return this.resolveTargetDbPath(dbStorage, legacyFixedPath)
|
||||
}
|
||||
return this.resolveTargetDbPath(dbStorage, db.relativePath)
|
||||
}
|
||||
|
||||
private findFirstExisting(paths: string[]): string {
|
||||
for (const path of paths) {
|
||||
try {
|
||||
if (existsSync(path) && statSync(path).isFile()) return path
|
||||
} catch {}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private resolveKnownDbPath(kind: BackupDbKind, dbStorage: string): string {
|
||||
if (kind === 'session') {
|
||||
return this.findFirstExisting([
|
||||
join(dbStorage, 'session', 'session.db'),
|
||||
join(dbStorage, 'Session', 'session.db'),
|
||||
join(dbStorage, 'session.db')
|
||||
])
|
||||
}
|
||||
if (kind === 'contact') {
|
||||
return this.findFirstExisting([
|
||||
join(dbStorage, 'Contact', 'contact.db'),
|
||||
join(dbStorage, 'Contact', 'Contact.db'),
|
||||
join(dbStorage, 'contact', 'contact.db'),
|
||||
join(dbStorage, 'session', 'contact.db')
|
||||
])
|
||||
}
|
||||
if (kind === 'emoticon') {
|
||||
return this.findFirstExisting([
|
||||
join(dbStorage, 'emoticon', 'emoticon.db'),
|
||||
join(dbStorage, 'emotion', 'emoticon.db')
|
||||
])
|
||||
}
|
||||
if (kind === 'sns') {
|
||||
return this.findFirstExisting([
|
||||
join(dbStorage, 'sns', 'sns.db'),
|
||||
join(dirname(dbStorage), 'sns', 'sns.db')
|
||||
])
|
||||
}
|
||||
if (kind === 'hardlink') {
|
||||
return this.findFirstExisting([
|
||||
join(dbStorage, 'hardlink', 'hardlink.db'),
|
||||
join(dbStorage, 'hardlink.db'),
|
||||
join(dirname(dbStorage), 'hardlink.db')
|
||||
])
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private async collectDatabases(dbStorage: string): Promise<Array<Omit<BackupDbEntry, 'tables'>>> {
|
||||
const result: Array<Omit<BackupDbEntry, 'tables'>> = []
|
||||
for (const kind of ['session', 'contact', 'emoticon', 'sns', 'hardlink'] as const) {
|
||||
const dbPath = this.resolveKnownDbPath(kind, dbStorage)
|
||||
result.push({
|
||||
id: kind,
|
||||
kind,
|
||||
dbPath,
|
||||
relativePath: dbPath ? this.toDbRelativePath(dbStorage, dbPath) : kind
|
||||
})
|
||||
}
|
||||
|
||||
const messageDbs = await wcdbService.listMessageDbs()
|
||||
if (messageDbs.success && Array.isArray(messageDbs.data)) {
|
||||
messageDbs.data.forEach((dbPath, index) => {
|
||||
result.push({
|
||||
id: this.buildDbId('message', index, dbPath),
|
||||
kind: 'message',
|
||||
dbPath,
|
||||
relativePath: this.toDbRelativePath(dbStorage, dbPath)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const mediaDbs = await wcdbService.listMediaDbs()
|
||||
if (mediaDbs.success && Array.isArray(mediaDbs.data)) {
|
||||
mediaDbs.data.forEach((dbPath, index) => {
|
||||
result.push({
|
||||
id: this.buildDbId('media', index, dbPath),
|
||||
kind: 'media',
|
||||
dbPath,
|
||||
relativePath: this.toDbRelativePath(dbStorage, dbPath)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async collectImageResources(
|
||||
connected: { wxid: string; dbStorage: string },
|
||||
stagingDir: string,
|
||||
manifest: BackupManifest
|
||||
): Promise<void> {
|
||||
const accountDir = dirname(connected.dbStorage)
|
||||
const imagesDir = join(stagingDir, 'resources', 'images')
|
||||
const imagePaths = await this.listChatImageDatFiles(accountDir)
|
||||
if (imagePaths.length === 0) return
|
||||
|
||||
mkdirSync(imagesDir, { recursive: true })
|
||||
const resources: BackupResourceEntry[] = []
|
||||
const emitImageProgress = createThrottledProgressEmitter(160)
|
||||
for (let index = 0; index < imagePaths.length; index += 1) {
|
||||
const sourcePath = imagePaths[index]
|
||||
const relativeTarget = this.isSafeAccountRelativePath(accountDir, sourcePath)
|
||||
if (!relativeTarget) continue
|
||||
emitImageProgress({
|
||||
phase: 'exporting',
|
||||
message: '正在打包图片资源',
|
||||
current: index + 1,
|
||||
total: imagePaths.length,
|
||||
detail: relativeTarget
|
||||
})
|
||||
const archivePath = toArchivePath(join('resources', 'images', relativeTarget))
|
||||
const outputPath = join(stagingDir, archivePath)
|
||||
await this.stagePlainResource(sourcePath, outputPath)
|
||||
const stem = basename(sourcePath).replace(/\.dat$/i, '').toLowerCase()
|
||||
const stat = statSync(sourcePath)
|
||||
resources.push({
|
||||
kind: 'image',
|
||||
id: relativeTarget,
|
||||
md5: /^[a-f0-9]{32}$/i.test(stem) ? stem : undefined,
|
||||
sourceFileName: basename(sourcePath),
|
||||
archivePath,
|
||||
targetRelativePath: relativeTarget,
|
||||
size: stat.size
|
||||
})
|
||||
if (index % 20 === 0) await delay()
|
||||
}
|
||||
|
||||
if (resources.length > 0) {
|
||||
manifest.resources = { ...(manifest.resources || {}), images: resources }
|
||||
}
|
||||
}
|
||||
|
||||
private async collectPlainResources(
|
||||
connected: { dbStorage: string },
|
||||
stagingDir: string,
|
||||
manifest: BackupManifest,
|
||||
kind: 'video' | 'file'
|
||||
): Promise<void> {
|
||||
const accountDir = dirname(connected.dbStorage)
|
||||
const roots = kind === 'video'
|
||||
? [
|
||||
join(accountDir, 'msg', 'video'),
|
||||
join(accountDir, 'FileStorage', 'Video')
|
||||
]
|
||||
: [
|
||||
join(accountDir, 'FileStorage', 'File'),
|
||||
join(accountDir, 'msg', 'file')
|
||||
]
|
||||
const listed = await Promise.all(roots.map(root => this.listFilesUnderDir(root)))
|
||||
const uniqueFiles = Array.from(new Set(listed.flat()))
|
||||
if (uniqueFiles.length === 0) return
|
||||
|
||||
const resources: BackupResourceEntry[] = []
|
||||
const bucket = kind === 'video' ? 'videos' : 'files'
|
||||
const emitResourceProgress = createThrottledProgressEmitter(180)
|
||||
await runWithConcurrency(uniqueFiles, 4, async (sourcePath, index) => {
|
||||
emitResourceProgress({
|
||||
phase: 'exporting',
|
||||
message: kind === 'video' ? '正在归档视频资源' : '正在归档文件资源',
|
||||
current: index + 1,
|
||||
total: uniqueFiles.length,
|
||||
detail: basename(sourcePath)
|
||||
})
|
||||
const relativeTarget = this.isSafeAccountRelativePath(accountDir, sourcePath)
|
||||
if (!relativeTarget) return
|
||||
const archivePath = toArchivePath(join('resources', bucket, relativeTarget))
|
||||
const outputPath = join(stagingDir, archivePath)
|
||||
await this.stagePlainResource(sourcePath, outputPath)
|
||||
let size = 0
|
||||
try { size = statSync(sourcePath).size } catch {}
|
||||
const entry: BackupResourceEntry = {
|
||||
kind,
|
||||
id: relativeTarget,
|
||||
sourceFileName: basename(sourcePath),
|
||||
archivePath,
|
||||
targetRelativePath: relativeTarget,
|
||||
size
|
||||
}
|
||||
resources.push(entry)
|
||||
})
|
||||
|
||||
if (resources.length > 0) {
|
||||
manifest.resources = {
|
||||
...(manifest.resources || {}),
|
||||
[bucket]: resources
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createBackup(outputPath: string, options: BackupOptions = {}): Promise<{ success: boolean; filePath?: string; manifest?: BackupManifest; error?: string }> {
|
||||
let stagingDir = ''
|
||||
try {
|
||||
emitBackupProgress({ phase: 'preparing', message: '正在连接数据库' })
|
||||
const connected = await this.ensureConnected()
|
||||
if (!connected.success || !connected.wxid || !connected.dbPath || !connected.dbStorage) {
|
||||
return { success: false, error: connected.error || '数据库未连接' }
|
||||
}
|
||||
|
||||
stagingDir = await this.createTempDir('weflow-backup-')
|
||||
const snapshotsDir = join(stagingDir, 'snapshots')
|
||||
mkdirSync(snapshotsDir, { recursive: true })
|
||||
|
||||
const dbs = await this.collectDatabases(connected.dbStorage)
|
||||
const manifest: BackupManifest = {
|
||||
version: 1,
|
||||
type: 'weflow-db-snapshots',
|
||||
createdAt: new Date().toISOString(),
|
||||
appVersion: app.getVersion(),
|
||||
source: {
|
||||
wxid: connected.wxid,
|
||||
dbRoot: connected.dbPath
|
||||
},
|
||||
databases: [],
|
||||
options: {
|
||||
includeImages: options.includeImages === true,
|
||||
includeVideos: options.includeVideos === true,
|
||||
includeFiles: options.includeFiles === true
|
||||
}
|
||||
}
|
||||
|
||||
const tableJobs: Array<{ db: Omit<BackupDbEntry, 'tables'>; table: string; schemaSql: string; snapshotPath: string; outputPath: string }> = []
|
||||
for (let index = 0; index < dbs.length; index += 1) {
|
||||
const db = dbs[index]
|
||||
emitBackupProgress({
|
||||
phase: 'scanning',
|
||||
message: '正在扫描数据库和表',
|
||||
current: index + 1,
|
||||
total: dbs.length,
|
||||
detail: `${db.kind}:${db.relativePath || db.dbPath || db.id}`
|
||||
})
|
||||
const tablesResult = await wcdbService.listTables(db.kind, db.dbPath)
|
||||
if (!tablesResult.success || !Array.isArray(tablesResult.tables) || tablesResult.tables.length === 0) continue
|
||||
const dbDir = join(snapshotsDir, db.id)
|
||||
mkdirSync(dbDir, { recursive: true })
|
||||
const entry: BackupDbEntry = { ...db, tables: [] }
|
||||
manifest.databases.push(entry)
|
||||
for (const table of tablesResult.tables) {
|
||||
const schemaResult = await wcdbService.getTableSchema(db.kind, db.dbPath, table)
|
||||
if (!schemaResult.success || !schemaResult.schema) continue
|
||||
const snapshotPath = toArchivePath(join('snapshots', db.id, `${safeName(table)}.wfsnap`))
|
||||
tableJobs.push({
|
||||
db,
|
||||
table,
|
||||
schemaSql: schemaResult.schema,
|
||||
snapshotPath,
|
||||
outputPath: join(stagingDir, snapshotPath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let current = 0
|
||||
for (const job of tableJobs) {
|
||||
current++
|
||||
emitBackupProgress({
|
||||
phase: 'exporting',
|
||||
message: '正在导出数据库快照',
|
||||
current,
|
||||
total: tableJobs.length,
|
||||
detail: `${job.db.kind}:${job.table}`
|
||||
})
|
||||
const exported = await wcdbService.exportTableSnapshot(job.db.kind, job.db.dbPath, job.table, job.outputPath)
|
||||
if (!exported.success) {
|
||||
throw new Error(`${job.db.kind}:${job.table} 导出失败:${exported.error || 'unknown'}`)
|
||||
}
|
||||
const dbEntry = manifest.databases.find(item => item.id === job.db.id)
|
||||
dbEntry?.tables.push({
|
||||
name: job.table,
|
||||
snapshotPath: job.snapshotPath,
|
||||
rows: exported.rows || 0,
|
||||
columns: exported.columns || 0,
|
||||
schemaSql: job.schemaSql
|
||||
})
|
||||
}
|
||||
|
||||
if (options.includeImages === true) {
|
||||
await this.collectImageResources(
|
||||
{ wxid: connected.wxid, dbStorage: connected.dbStorage },
|
||||
stagingDir,
|
||||
manifest
|
||||
)
|
||||
}
|
||||
if (options.includeVideos === true) {
|
||||
await this.collectPlainResources({ dbStorage: connected.dbStorage }, stagingDir, manifest, 'video')
|
||||
}
|
||||
if (options.includeFiles === true) {
|
||||
await this.collectPlainResources({ dbStorage: connected.dbStorage }, stagingDir, manifest, 'file')
|
||||
}
|
||||
|
||||
await writeFile(join(stagingDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8')
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
const archiveFiles = await this.listFilesForArchive(stagingDir)
|
||||
const shouldCompress = !hasResourceOptions(options)
|
||||
let packed = 0
|
||||
const emitPackingProgress = createThrottledProgressEmitter(150)
|
||||
emitBackupProgress({ phase: 'packing', message: '正在生成备份包', current: 0, total: archiveFiles.length })
|
||||
await tar.c({
|
||||
gzip: shouldCompress ? { level: 1 } : false,
|
||||
cwd: stagingDir,
|
||||
file: outputPath,
|
||||
portable: true,
|
||||
noMtime: true,
|
||||
sync: false,
|
||||
onWriteEntry: (entry: any) => {
|
||||
packed += 1
|
||||
emitPackingProgress({
|
||||
phase: 'packing',
|
||||
message: '正在写入备份包',
|
||||
current: Math.min(packed, archiveFiles.length),
|
||||
total: archiveFiles.length,
|
||||
detail: String(entry?.path || entry || '')
|
||||
})
|
||||
}
|
||||
} as any, archiveFiles)
|
||||
emitBackupProgress({
|
||||
phase: 'packing',
|
||||
message: '正在写入备份包',
|
||||
current: archiveFiles.length,
|
||||
total: archiveFiles.length
|
||||
})
|
||||
emitBackupProgress({ phase: 'done', message: '备份完成', current: tableJobs.length, total: tableJobs.length })
|
||||
return { success: true, filePath: outputPath, manifest }
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : String(e)
|
||||
emitBackupProgress({ phase: 'failed', message: error })
|
||||
return { success: false, error }
|
||||
} finally {
|
||||
if (stagingDir) {
|
||||
try { rmSync(stagingDir, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async inspectBackup(archivePath: string): Promise<{ success: boolean; manifest?: BackupManifest; error?: string }> {
|
||||
let extractDir = ''
|
||||
try {
|
||||
emitBackupProgress({ phase: 'inspecting', message: '正在读取备份包' })
|
||||
extractDir = await this.createTempDir('weflow-backup-inspect-')
|
||||
await tar.x({
|
||||
file: archivePath,
|
||||
cwd: extractDir,
|
||||
filter: (entryPath: string) => entryPath.replace(/\\/g, '/') === 'manifest.json'
|
||||
} as any)
|
||||
const manifestPath = join(extractDir, 'manifest.json')
|
||||
if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' }
|
||||
const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest
|
||||
if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) {
|
||||
return { success: false, error: '不支持的备份包格式' }
|
||||
}
|
||||
return { success: true, manifest }
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : String(e) }
|
||||
} finally {
|
||||
if (extractDir) {
|
||||
try { rmSync(extractDir, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async streamRestoreArchive(
|
||||
archivePath: string,
|
||||
extractDir: string,
|
||||
manifest: BackupManifest,
|
||||
connected: { dbStorage: string; wxid?: string },
|
||||
startCurrent: number,
|
||||
total: number
|
||||
): Promise<{ current: number; skipped: number }> {
|
||||
const snapshotPaths = new Set<string>()
|
||||
for (const db of manifest.databases || []) {
|
||||
for (const table of db.tables || []) {
|
||||
const path = normalizeArchivePath(table.snapshotPath)
|
||||
if (path) snapshotPaths.add(path)
|
||||
}
|
||||
}
|
||||
|
||||
const imageByPath = new Map<string, BackupResourceEntry>()
|
||||
for (const image of manifest.resources?.images || []) {
|
||||
const path = normalizeArchivePath(image.archivePath)
|
||||
if (path) imageByPath.set(path, image)
|
||||
}
|
||||
|
||||
const plainByPath = new Map<string, BackupResourceEntry>()
|
||||
for (const resource of [
|
||||
...(manifest.resources?.videos || []),
|
||||
...(manifest.resources?.files || [])
|
||||
]) {
|
||||
const path = normalizeArchivePath(resource.archivePath)
|
||||
if (path) plainByPath.set(path, resource)
|
||||
}
|
||||
|
||||
const accountDir = dirname(connected.dbStorage)
|
||||
let current = startCurrent
|
||||
let skipped = 0
|
||||
const pending: Promise<void>[] = []
|
||||
const emitRestoreProgress = createThrottledProgressEmitter(160)
|
||||
await tar.t({
|
||||
file: archivePath,
|
||||
onReadEntry: (entry: any) => {
|
||||
const entryPath = normalizeArchivePath(entry.path)
|
||||
if (snapshotPaths.has(entryPath)) {
|
||||
const outputPath = this.resolveStagingPath(extractDir, entryPath)
|
||||
if (!outputPath) {
|
||||
entry.resume()
|
||||
return
|
||||
}
|
||||
pending.push(this.writeTarEntryToFile(entry, outputPath))
|
||||
return
|
||||
}
|
||||
|
||||
const image = imageByPath.get(entryPath)
|
||||
if (image) {
|
||||
const targetPath = this.resolveTargetResourcePath(accountDir, image.targetRelativePath)
|
||||
if (!targetPath) {
|
||||
skipped += 1
|
||||
entry.resume()
|
||||
return
|
||||
}
|
||||
current += 1
|
||||
emitRestoreProgress({
|
||||
phase: 'restoring',
|
||||
message: '正在写回图片资源',
|
||||
current,
|
||||
total,
|
||||
detail: image.md5 || image.targetRelativePath
|
||||
})
|
||||
if (existsSync(targetPath)) {
|
||||
skipped += 1
|
||||
entry.resume()
|
||||
return
|
||||
}
|
||||
pending.push(this.writeTarEntryToFile(entry, targetPath))
|
||||
return
|
||||
}
|
||||
|
||||
const resource = plainByPath.get(entryPath)
|
||||
if (resource) {
|
||||
const targetPath = this.resolveTargetResourcePath(accountDir, resource.targetRelativePath)
|
||||
current += 1
|
||||
emitRestoreProgress({
|
||||
phase: 'restoring',
|
||||
message: resource.kind === 'video' ? '正在写回视频资源' : '正在写回文件资源',
|
||||
current,
|
||||
total,
|
||||
detail: resource.targetRelativePath
|
||||
})
|
||||
if (!targetPath || existsSync(targetPath)) {
|
||||
skipped += 1
|
||||
entry.resume()
|
||||
return
|
||||
}
|
||||
pending.push(this.writeTarEntryToFile(entry, targetPath))
|
||||
return
|
||||
}
|
||||
|
||||
entry.resume()
|
||||
}
|
||||
} as any)
|
||||
|
||||
await Promise.all(pending)
|
||||
return { current, skipped }
|
||||
}
|
||||
|
||||
async restoreBackup(archivePath: string): Promise<{ success: boolean; inserted?: number; ignored?: number; skipped?: number; error?: string }> {
|
||||
let extractDir = ''
|
||||
try {
|
||||
emitBackupProgress({ phase: 'inspecting', message: '正在读取备份信息' })
|
||||
extractDir = await this.createTempDir('weflow-backup-restore-')
|
||||
await tar.x({
|
||||
file: archivePath,
|
||||
cwd: extractDir,
|
||||
filter: (entryPath: string) => normalizeArchivePath(entryPath) === 'manifest.json'
|
||||
} as any)
|
||||
const manifestPath = join(extractDir, 'manifest.json')
|
||||
if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' }
|
||||
const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest
|
||||
if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) {
|
||||
return { success: false, error: '不支持的备份包格式' }
|
||||
}
|
||||
const targetWxid = String(manifest.source?.wxid || '').trim()
|
||||
if (!targetWxid) return { success: false, error: '备份包缺少来源账号 wxid,无法定位目标账号目录' }
|
||||
|
||||
emitBackupProgress({ phase: 'preparing', message: '正在连接目标数据库', detail: targetWxid })
|
||||
const connected = await this.ensureConnected(targetWxid)
|
||||
if (!connected.success || !connected.dbStorage) return { success: false, error: connected.error || '数据库未连接' }
|
||||
|
||||
const tableJobs = manifest.databases.flatMap(db => db.tables.map(table => ({ db, table })))
|
||||
const imageJobs = manifest.resources?.images || []
|
||||
const plainResourceJobs = [
|
||||
...(manifest.resources?.videos || []),
|
||||
...(manifest.resources?.files || [])
|
||||
]
|
||||
const totalRestoreJobs = tableJobs.length + imageJobs.length + plainResourceJobs.length
|
||||
let inserted = 0
|
||||
let ignored = 0
|
||||
let skipped = 0
|
||||
let current = 0
|
||||
if (imageJobs.length > 0 || plainResourceJobs.length > 0 || tableJobs.length > 0) {
|
||||
emitBackupProgress({
|
||||
phase: 'inspecting',
|
||||
message: '正在按需读取备份包',
|
||||
current: 0,
|
||||
total: totalRestoreJobs,
|
||||
detail: archivePath
|
||||
})
|
||||
const streamed = await this.streamRestoreArchive(
|
||||
archivePath,
|
||||
extractDir,
|
||||
manifest,
|
||||
{ dbStorage: connected.dbStorage, wxid: connected.wxid },
|
||||
0,
|
||||
totalRestoreJobs
|
||||
)
|
||||
current = streamed.current
|
||||
skipped += streamed.skipped
|
||||
}
|
||||
|
||||
for (const job of tableJobs) {
|
||||
current++
|
||||
const targetDbPath = this.resolveRestoreTargetDbPath(connected.dbStorage, job.db)
|
||||
if (targetDbPath === null) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if (!job.table.schemaSql) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
emitBackupProgress({
|
||||
phase: 'restoring',
|
||||
message: '正在通过 WCDB 写入数据库',
|
||||
current,
|
||||
total: totalRestoreJobs,
|
||||
detail: `${job.db.kind}:${job.table.name}`
|
||||
})
|
||||
const inputPath = this.resolveExtractedPath(extractDir, job.table.snapshotPath)
|
||||
if (!inputPath || !existsSync(inputPath)) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
mkdirSync(dirname(targetDbPath), { recursive: true })
|
||||
const restored = await wcdbService.importTableSnapshotWithSchema(
|
||||
job.db.kind,
|
||||
targetDbPath,
|
||||
job.table.name,
|
||||
inputPath,
|
||||
job.table.schemaSql
|
||||
)
|
||||
if (!restored.success) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
inserted += restored.inserted || 0
|
||||
ignored += restored.ignored || 0
|
||||
if (current % 4 === 0) await delay()
|
||||
}
|
||||
|
||||
emitBackupProgress({ phase: 'done', message: '载入完成', current: totalRestoreJobs, total: totalRestoreJobs })
|
||||
return { success: true, inserted, ignored, skipped }
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : String(e)
|
||||
emitBackupProgress({ phase: 'failed', message: error })
|
||||
return { success: false, error }
|
||||
} finally {
|
||||
if (extractDir) {
|
||||
try { rmSync(extractDir, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const backupService = new BackupService()
|
||||
@@ -666,6 +666,9 @@ class ChatService {
|
||||
if (this.connected && wcdbService.isReady()) {
|
||||
return { success: true }
|
||||
}
|
||||
if (!wcdbService.isReady()) {
|
||||
this.monitorSetup = false
|
||||
}
|
||||
const result = await this.connect()
|
||||
if (!result.success) {
|
||||
this.connected = false
|
||||
@@ -709,6 +712,7 @@ class ChatService {
|
||||
console.error('ChatService: 关闭数据库失败:', e)
|
||||
}
|
||||
this.connected = false
|
||||
this.monitorSetup = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -745,8 +749,12 @@ class ChatService {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) return { success: false, error: connectResult.error }
|
||||
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||
return await wcdbService.checkMessageAntiRevokeTriggers(normalizedIds)
|
||||
const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds)
|
||||
const result = validIds.length > 0
|
||||
? await wcdbService.checkMessageAntiRevokeTriggers(validIds)
|
||||
: { success: true, rows: [] }
|
||||
if (!result.success) return result
|
||||
return { success: true, rows: [...(result.rows || []), ...invalidRows] }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
@@ -760,8 +768,12 @@ class ChatService {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) return { success: false, error: connectResult.error }
|
||||
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||
return await wcdbService.installMessageAntiRevokeTriggers(normalizedIds)
|
||||
const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds)
|
||||
const result = validIds.length > 0
|
||||
? await wcdbService.installMessageAntiRevokeTriggers(validIds)
|
||||
: { success: true, rows: [] }
|
||||
if (!result.success) return result
|
||||
return { success: true, rows: [...(result.rows || []), ...invalidRows] }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
@@ -775,8 +787,12 @@ class ChatService {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) return { success: false, error: connectResult.error }
|
||||
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||
return await wcdbService.uninstallMessageAntiRevokeTriggers(normalizedIds)
|
||||
const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds)
|
||||
const result = validIds.length > 0
|
||||
? await wcdbService.uninstallMessageAntiRevokeTriggers(validIds)
|
||||
: { success: true, rows: [] }
|
||||
if (!result.success) return result
|
||||
return { success: true, rows: [...(result.rows || []), ...invalidRows] }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
@@ -934,6 +950,191 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
async getAntiRevokeSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> {
|
||||
try {
|
||||
const result = await this.getSessions()
|
||||
if (!result.success || !Array.isArray(result.sessions)) {
|
||||
return { success: false, error: result.error || '获取会话失败' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessions: result.sessions.filter((session) => !String(session.username || '').startsWith('gh_'))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('ChatService: 获取防撤回会话列表失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
private getSessionUsername(row: Record<string, any>): string {
|
||||
return String(
|
||||
row.username ||
|
||||
row.user_name ||
|
||||
row.userName ||
|
||||
row.usrName ||
|
||||
row.UsrName ||
|
||||
row.talker ||
|
||||
row.talker_id ||
|
||||
row.talkerId ||
|
||||
''
|
||||
).trim()
|
||||
}
|
||||
|
||||
private isAntiRevokeContactRow(username: string, row: Record<string, any>): boolean {
|
||||
if (!username) return false
|
||||
if (username.endsWith('@chatroom')) return true
|
||||
if (username.startsWith('gh_')) return false
|
||||
|
||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], Number.NaN)
|
||||
const lowered = username.toLowerCase()
|
||||
if (this.isEnterpriseOpenimUsername(username)) {
|
||||
return this.isAllowedEnterpriseOpenimByLocalType(username, localType)
|
||||
}
|
||||
if (lowered.startsWith('weixin') && lowered !== 'weixin') return true
|
||||
return localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)
|
||||
}
|
||||
|
||||
private async loadAntiRevokeContactMap(usernames: string[]): Promise<Map<string, { displayName?: string }>> {
|
||||
const targets = Array.from(new Set((usernames || []).map((value) => String(value || '').trim()).filter(Boolean)))
|
||||
const map = new Map<string, { displayName?: string }>()
|
||||
if (targets.length === 0) return map
|
||||
|
||||
try {
|
||||
const contactResult = await wcdbService.getContactsCompact(targets)
|
||||
if (!contactResult.success || !Array.isArray(contactResult.contacts)) return map
|
||||
|
||||
for (const row of contactResult.contacts as Record<string, any>[]) {
|
||||
const username = String(row.username || '').trim()
|
||||
if (!username || !this.isAntiRevokeContactRow(username, row)) continue
|
||||
map.set(username, {
|
||||
displayName: String(row.remark || row.nick_name || row.nickName || row.alias || username).trim()
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
return map
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
private async hasAntiRevokeMessageTables(sessionId: string): Promise<boolean> {
|
||||
try {
|
||||
const tableStatsResult = await wcdbService.getMessageTableStats(sessionId)
|
||||
if (!tableStatsResult.success || !Array.isArray(tableStatsResult.tables)) return false
|
||||
return tableStatsResult.tables.some((row: Record<string, any>) => {
|
||||
const tableName = String(row.table_name || row.tableName || '').trim()
|
||||
return tableName.length > 0
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async buildAntiRevokeSessionsFromRows(rows: Record<string, any>[]): Promise<ChatSession[]> {
|
||||
if (rows.length > 0 && (rows[0]._error || rows[0]._info)) return []
|
||||
|
||||
const candidateRows: Array<{ username: string; row: Record<string, any> }> = []
|
||||
const privateCandidateIds: string[] = []
|
||||
const openimLocalTypeMap = await this.loadContactLocalTypeMapForEnterpriseOpenim(rows.map((row) => this.getSessionUsername(row)))
|
||||
|
||||
for (const row of rows) {
|
||||
const username = this.getSessionUsername(row)
|
||||
if (!username) continue
|
||||
|
||||
let sessionLocalType = this.getSessionLocalType(row)
|
||||
if (!Number.isFinite(sessionLocalType) && this.isEnterpriseOpenimUsername(username)) {
|
||||
sessionLocalType = openimLocalTypeMap.get(username)
|
||||
}
|
||||
if (!this.shouldKeepSession(username, sessionLocalType)) continue
|
||||
|
||||
if (username.endsWith('@chatroom')) {
|
||||
candidateRows.push({ username, row })
|
||||
} else {
|
||||
privateCandidateIds.push(username)
|
||||
candidateRows.push({ username, row })
|
||||
}
|
||||
}
|
||||
|
||||
const contactMap = await this.loadAntiRevokeContactMap(privateCandidateIds)
|
||||
const sessions: ChatSession[] = []
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
const now = Date.now()
|
||||
|
||||
for (const { username, row } of candidateRows) {
|
||||
const isGroup = username.endsWith('@chatroom')
|
||||
if (!isGroup && !contactMap.has(username)) continue
|
||||
if (!await this.hasAntiRevokeMessageTables(username)) continue
|
||||
|
||||
const sortTs = parseInt(
|
||||
row.sort_timestamp ||
|
||||
row.sortTimestamp ||
|
||||
row.sort_time ||
|
||||
row.sortTime ||
|
||||
'0',
|
||||
10
|
||||
)
|
||||
const lastTs = parseInt(
|
||||
row.last_timestamp ||
|
||||
row.lastTimestamp ||
|
||||
row.last_msg_time ||
|
||||
row.lastMsgTime ||
|
||||
String(sortTs),
|
||||
10
|
||||
)
|
||||
const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '')
|
||||
const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10)
|
||||
const cached = this.avatarCache.get(username)
|
||||
const contact = contactMap.get(username)
|
||||
|
||||
const session: ChatSession = {
|
||||
username,
|
||||
type: parseInt(row.type || '0', 10),
|
||||
unreadCount: parseInt(row.unread_count || row.unreadCount || row.unreadcount || '0', 10),
|
||||
summary: summary || this.getMessageTypeLabel(lastMsgType),
|
||||
sortTimestamp: sortTs,
|
||||
lastTimestamp: lastTs,
|
||||
lastMsgType,
|
||||
displayName: contact?.displayName || cached?.displayName || username,
|
||||
avatarUrl: cached?.avatarUrl,
|
||||
lastMsgSender: row.last_msg_sender,
|
||||
lastSenderDisplayName: row.last_sender_display_name,
|
||||
selfWxid: myWxid
|
||||
}
|
||||
|
||||
const cachedStatus = this.sessionStatusCache.get(username)
|
||||
if (cachedStatus && now - cachedStatus.updatedAt <= this.sessionStatusCacheTtlMs) {
|
||||
session.isFolded = cachedStatus.isFolded
|
||||
session.isMuted = cachedStatus.isMuted
|
||||
}
|
||||
|
||||
sessions.push(session)
|
||||
}
|
||||
|
||||
return sessions
|
||||
}
|
||||
|
||||
private async filterAntiRevokeSessionIds(sessionIds: string[]): Promise<{
|
||||
validIds: string[]
|
||||
invalidRows: Array<{ sessionId: string; success: false; error: string }>
|
||||
}> {
|
||||
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||
if (normalizedIds.length === 0) return { validIds: [], invalidRows: [] }
|
||||
|
||||
const sessionsResult = await this.getAntiRevokeSessions()
|
||||
const allowedIds = new Set((sessionsResult.sessions || []).map((session) => session.username))
|
||||
const validIds = normalizedIds.filter((sessionId) => allowedIds.has(sessionId))
|
||||
const invalidRows = normalizedIds
|
||||
.filter((sessionId) => !allowedIds.has(sessionId))
|
||||
.map((sessionId) => ({
|
||||
sessionId,
|
||||
success: false as const,
|
||||
error: '该会话不是联系人或群聊,或不存在可安装防撤回的消息表'
|
||||
}))
|
||||
|
||||
return { validIds, invalidRows }
|
||||
}
|
||||
|
||||
private async addMissingOfficialSessions(sessions: ChatSession[], myWxid?: string): Promise<void> {
|
||||
const existing = new Set(sessions.map((session) => String(session.username || '').trim()).filter(Boolean))
|
||||
try {
|
||||
@@ -4609,6 +4810,7 @@ class ChatService {
|
||||
const createTime = this.getRowTimestampSeconds(row, ['create_time', 'createTime', 'msg_time', 'msgTime', 'time'], 0)
|
||||
const sortSeq = this.getRowInt(row, ['sort_seq'], createTime > 0 ? createTime * 1000 : 0)
|
||||
const localId = this.getRowInt(row, ['local_id'], 0)
|
||||
const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id)
|
||||
const serverId = this.getRowInt(row, ['server_id'], 0)
|
||||
const content = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
|
||||
@@ -4635,6 +4837,7 @@ class ChatService {
|
||||
}),
|
||||
localId,
|
||||
serverId,
|
||||
serverIdRaw,
|
||||
localType,
|
||||
createTime,
|
||||
sortSeq,
|
||||
|
||||
@@ -36,6 +36,7 @@ interface ConfigSchema {
|
||||
language: string
|
||||
logEnabled: boolean
|
||||
launchAtStartup?: boolean
|
||||
silentStartup?: boolean
|
||||
llmModelPath: string
|
||||
whisperModelName: string
|
||||
whisperModelDir: string
|
||||
@@ -163,6 +164,7 @@ export class ConfigService {
|
||||
themeId: 'cloud-dancer',
|
||||
language: 'zh-CN',
|
||||
logEnabled: false,
|
||||
silentStartup: false,
|
||||
llmModelPath: '',
|
||||
whisperModelName: 'base',
|
||||
whisperModelDir: '',
|
||||
|
||||
@@ -200,6 +200,8 @@ interface MediaSourceResolution {
|
||||
interface ExportTaskControl {
|
||||
shouldPause?: () => boolean
|
||||
shouldStop?: () => boolean
|
||||
recordCreatedFile?: (filePath: string) => void
|
||||
recordCreatedDir?: (dirPath: string) => void
|
||||
}
|
||||
|
||||
interface ExportStatsResult {
|
||||
@@ -279,6 +281,7 @@ class ExportService {
|
||||
private readonly exportAggregatedSessionStatsCacheTtlMs = 60 * 1000
|
||||
private readonly exportStatsCacheMaxEntries = 16
|
||||
private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED'
|
||||
private readonly PAUSE_ERROR_CODE = 'WEFLOW_EXPORT_PAUSE_REQUESTED'
|
||||
private mediaFileCachePopulatePending = new Map<string, Promise<string | null>>()
|
||||
private mediaFileCacheReadyDirs = new Set<string>()
|
||||
private mediaExportTelemetry: MediaExportTelemetry | null = null
|
||||
@@ -311,6 +314,12 @@ class ExportService {
|
||||
return error
|
||||
}
|
||||
|
||||
private createPauseError(): Error {
|
||||
const error = new Error('导出任务已暂停')
|
||||
;(error as Error & { code?: string }).code = this.PAUSE_ERROR_CODE
|
||||
return error
|
||||
}
|
||||
|
||||
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string } | null): void {
|
||||
this.runtimeConfig = config
|
||||
}
|
||||
@@ -453,10 +462,42 @@ class ExportService {
|
||||
return false
|
||||
}
|
||||
|
||||
private isPauseError(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
if (typeof error === 'string') {
|
||||
return error.includes(this.PAUSE_ERROR_CODE) || error.includes('导出任务已暂停')
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const code = (error as Error & { code?: string }).code
|
||||
return code === this.PAUSE_ERROR_CODE || error.message.includes(this.PAUSE_ERROR_CODE) || error.message.includes('导出任务已暂停')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private throwIfStopRequested(control?: ExportTaskControl): void {
|
||||
if (control?.shouldStop?.()) {
|
||||
throw this.createStopError()
|
||||
}
|
||||
if (control?.shouldPause?.()) {
|
||||
throw this.createPauseError()
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureExportDir(dirPath: string, control?: ExportTaskControl, dirCache?: Set<string>): Promise<void> {
|
||||
if (dirCache?.has(dirPath)) return
|
||||
const existed = await this.pathExists(dirPath)
|
||||
await fs.promises.mkdir(dirPath, { recursive: true })
|
||||
dirCache?.add(dirPath)
|
||||
if (!existed) {
|
||||
control?.recordCreatedDir?.(dirPath)
|
||||
}
|
||||
}
|
||||
|
||||
private async recordCreatedFileBeforeWrite(filePath: string, control?: ExportTaskControl): Promise<void> {
|
||||
if (!control?.recordCreatedFile) return
|
||||
if (!await this.pathExists(filePath)) {
|
||||
control.recordCreatedFile(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number {
|
||||
@@ -850,8 +891,10 @@ class ExportService {
|
||||
private async copyMediaWithCacheAndDedup(
|
||||
kind: 'image' | 'video' | 'emoji',
|
||||
sourcePath: string,
|
||||
destPath: string
|
||||
destPath: string,
|
||||
control?: ExportTaskControl
|
||||
): Promise<{ success: boolean; code?: string }> {
|
||||
const existedBeforeCopy = await this.pathExists(destPath)
|
||||
const resolved = await this.resolvePreferredMediaSource(kind, sourcePath)
|
||||
if (resolved.cacheHit) {
|
||||
this.noteMediaTelemetry({ cacheHitFiles: 1 })
|
||||
@@ -870,6 +913,9 @@ class ExportService {
|
||||
dedupReuseFiles: 1,
|
||||
bytesWritten: resolved.fileStat?.size || 0
|
||||
})
|
||||
if (!existedBeforeCopy) {
|
||||
control?.recordCreatedFile?.(destPath)
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
}
|
||||
@@ -886,6 +932,9 @@ class ExportService {
|
||||
doneFiles: 1,
|
||||
bytesWritten: resolved.fileStat?.size || 0
|
||||
})
|
||||
if (!existedBeforeCopy) {
|
||||
control?.recordCreatedFile?.(destPath)
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
@@ -3962,6 +4011,7 @@ class ExportService {
|
||||
includeVideoPoster?: boolean
|
||||
includeVoiceWithTranscript?: boolean
|
||||
dirCache?: Set<string>
|
||||
control?: ExportTaskControl
|
||||
}
|
||||
): Promise<MediaExportItem | null> {
|
||||
const localType = msg.localType
|
||||
@@ -3973,7 +4023,8 @@ class ExportService {
|
||||
sessionId,
|
||||
mediaRootDir,
|
||||
mediaRelativePrefix,
|
||||
options.dirCache
|
||||
options.dirCache,
|
||||
options.control
|
||||
)
|
||||
if (result) {
|
||||
}
|
||||
@@ -3983,7 +4034,7 @@ class ExportService {
|
||||
// 语音消息
|
||||
if (localType === 34) {
|
||||
if (options.exportVoices) {
|
||||
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache)
|
||||
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control)
|
||||
}
|
||||
if (options.exportVoiceAsText) {
|
||||
return null
|
||||
@@ -3992,7 +4043,7 @@ class ExportService {
|
||||
|
||||
// 动画表情
|
||||
if (localType === 47 && options.exportEmojis) {
|
||||
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache)
|
||||
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control)
|
||||
if (result) {
|
||||
}
|
||||
return result
|
||||
@@ -4005,7 +4056,8 @@ class ExportService {
|
||||
mediaRootDir,
|
||||
mediaRelativePrefix,
|
||||
options.dirCache,
|
||||
options.includeVideoPoster === true
|
||||
options.includeVideoPoster === true,
|
||||
options.control
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4015,7 +4067,8 @@ class ExportService {
|
||||
mediaRootDir,
|
||||
mediaRelativePrefix,
|
||||
options.maxFileSizeMb,
|
||||
options.dirCache
|
||||
options.dirCache,
|
||||
options.control
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4030,14 +4083,12 @@ class ExportService {
|
||||
sessionId: string,
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string,
|
||||
dirCache?: Set<string>
|
||||
dirCache?: Set<string>,
|
||||
control?: ExportTaskControl
|
||||
): Promise<MediaExportItem | null> {
|
||||
try {
|
||||
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
|
||||
if (!dirCache?.has(imagesDir)) {
|
||||
await fs.promises.mkdir(imagesDir, { recursive: true })
|
||||
dirCache?.add(imagesDir)
|
||||
}
|
||||
await this.ensureExportDir(imagesDir, control, dirCache)
|
||||
|
||||
const tryResolveImagePath = async (imageMd5?: string, imageDatName?: string): Promise<string | null> => {
|
||||
if (!imageMd5 && !imageDatName) return null
|
||||
@@ -4123,6 +4174,7 @@ class ExportService {
|
||||
const destPath = path.join(imagesDir, fileName)
|
||||
|
||||
const buffer = Buffer.from(base64Data, 'base64')
|
||||
await this.recordCreatedFileBeforeWrite(destPath, control)
|
||||
await fs.promises.writeFile(destPath, buffer)
|
||||
this.noteMediaTelemetry({
|
||||
doneFiles: 1,
|
||||
@@ -4142,7 +4194,7 @@ class ExportService {
|
||||
const ext = path.extname(sourcePath) || '.jpg'
|
||||
const fileName = `${messageId}_${imageKey}${ext}`
|
||||
const destPath = path.join(imagesDir, fileName)
|
||||
const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath)
|
||||
const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath, control)
|
||||
if (!copied.success) {
|
||||
if (copied.code === 'ENOENT') {
|
||||
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
|
||||
@@ -4261,14 +4313,12 @@ class ExportService {
|
||||
sessionId: string,
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string,
|
||||
dirCache?: Set<string>
|
||||
dirCache?: Set<string>,
|
||||
control?: ExportTaskControl
|
||||
): Promise<MediaExportItem | null> {
|
||||
try {
|
||||
const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
|
||||
if (!dirCache?.has(voicesDir)) {
|
||||
await fs.promises.mkdir(voicesDir, { recursive: true })
|
||||
dirCache?.add(voicesDir)
|
||||
}
|
||||
await this.ensureExportDir(voicesDir, control, dirCache)
|
||||
|
||||
const msgId = String(msg.localId)
|
||||
const safeSession = this.cleanAccountDirName(sessionId)
|
||||
@@ -4300,6 +4350,7 @@ class ExportService {
|
||||
|
||||
// voiceResult.data 是 base64 编码的 wav 数据
|
||||
const wavBuffer = Buffer.from(voiceResult.data, 'base64')
|
||||
await this.recordCreatedFileBeforeWrite(destPath, control)
|
||||
await fs.promises.writeFile(destPath, wavBuffer)
|
||||
this.noteMediaTelemetry({
|
||||
doneFiles: 1,
|
||||
@@ -4338,14 +4389,12 @@ class ExportService {
|
||||
sessionId: string,
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string,
|
||||
dirCache?: Set<string>
|
||||
dirCache?: Set<string>,
|
||||
control?: ExportTaskControl
|
||||
): Promise<MediaExportItem | null> {
|
||||
try {
|
||||
const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
|
||||
if (!dirCache?.has(emojisDir)) {
|
||||
await fs.promises.mkdir(emojisDir, { recursive: true })
|
||||
dirCache?.add(emojisDir)
|
||||
}
|
||||
await this.ensureExportDir(emojisDir, control, dirCache)
|
||||
|
||||
// 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑)
|
||||
const localPath = await chatService.downloadEmojiFile(msg)
|
||||
@@ -4359,7 +4408,7 @@ class ExportService {
|
||||
const key = msg.emojiMd5 || String(msg.localId)
|
||||
const fileName = `${key}${ext}`
|
||||
const destPath = path.join(emojisDir, fileName)
|
||||
const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath)
|
||||
const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath, control)
|
||||
if (!copied.success) return null
|
||||
|
||||
return {
|
||||
@@ -4381,7 +4430,8 @@ class ExportService {
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string,
|
||||
dirCache?: Set<string>,
|
||||
includePoster = false
|
||||
includePoster = false,
|
||||
control?: ExportTaskControl
|
||||
): Promise<MediaExportItem | null> {
|
||||
try {
|
||||
let videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase()
|
||||
@@ -4404,16 +4454,13 @@ class ExportService {
|
||||
if (!videoInfo) return null
|
||||
|
||||
const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos')
|
||||
if (!dirCache?.has(videosDir)) {
|
||||
await fs.promises.mkdir(videosDir, { recursive: true })
|
||||
dirCache?.add(videosDir)
|
||||
}
|
||||
await this.ensureExportDir(videosDir, control, dirCache)
|
||||
|
||||
const sourcePath = videoInfo.videoUrl
|
||||
const fileName = path.basename(sourcePath)
|
||||
const destPath = path.join(videosDir, fileName)
|
||||
|
||||
const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath)
|
||||
const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath, control)
|
||||
if (!copied.success) return null
|
||||
|
||||
return {
|
||||
@@ -4864,7 +4911,8 @@ class ExportService {
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string,
|
||||
maxFileSizeMb?: number,
|
||||
dirCache?: Set<string>
|
||||
dirCache?: Set<string>,
|
||||
control?: ExportTaskControl
|
||||
): Promise<MediaExportItem | null> {
|
||||
try {
|
||||
const fileNameRaw = String(msg?.fileName || '').trim()
|
||||
@@ -4872,10 +4920,7 @@ class ExportService {
|
||||
|
||||
const fileExtDir = this.resolveFileAttachmentExtensionDir(msg, fileNameRaw)
|
||||
const fileDir = path.join(mediaRootDir, mediaRelativePrefix, 'file', fileExtDir)
|
||||
if (!dirCache?.has(fileDir)) {
|
||||
await fs.promises.mkdir(fileDir, { recursive: true })
|
||||
dirCache?.add(fileDir)
|
||||
}
|
||||
await this.ensureExportDir(fileDir, control, dirCache)
|
||||
|
||||
const candidates = await this.resolveFileAttachmentCandidates(msg)
|
||||
if (candidates.length === 0) {
|
||||
@@ -4919,6 +4964,7 @@ class ExportService {
|
||||
const messageId = String(msg?.localId || Date.now())
|
||||
const destFileName = `${messageId}_${safeBaseName}`
|
||||
const destPath = path.join(fileDir, destFileName)
|
||||
const existedBeforeCopy = await this.pathExists(destPath)
|
||||
const copied = await this.copyFileOptimized(selected.sourcePath, destPath)
|
||||
if (!copied.success) {
|
||||
this.recordFileAttachmentMiss(msg, '附件复制失败', {
|
||||
@@ -4929,6 +4975,9 @@ class ExportService {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!existedBeforeCopy) {
|
||||
control?.recordCreatedFile?.(destPath)
|
||||
}
|
||||
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
|
||||
return {
|
||||
relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName),
|
||||
@@ -5884,16 +5933,15 @@ class ExportService {
|
||||
*/
|
||||
private async exportAvatarsToFiles(
|
||||
members: Array<{ username: string; avatarUrl?: string }>,
|
||||
outputDir: string
|
||||
outputDir: string,
|
||||
control?: ExportTaskControl
|
||||
): Promise<Map<string, string>> {
|
||||
const result = new Map<string, string>()
|
||||
if (members.length === 0) return result
|
||||
|
||||
// 创建 avatars 子目录
|
||||
const avatarsDir = path.join(outputDir, 'avatars')
|
||||
if (!fs.existsSync(avatarsDir)) {
|
||||
fs.mkdirSync(avatarsDir, { recursive: true })
|
||||
}
|
||||
await this.ensureExportDir(avatarsDir, control)
|
||||
|
||||
const AVATAR_CONCURRENCY = 8
|
||||
await parallelLimit(members, AVATAR_CONCURRENCY, async (member) => {
|
||||
@@ -5934,6 +5982,7 @@ class ExportService {
|
||||
try {
|
||||
await fs.promises.access(avatarPath)
|
||||
} catch {
|
||||
await this.recordCreatedFileBeforeWrite(avatarPath, control)
|
||||
await fs.promises.writeFile(avatarPath, data)
|
||||
}
|
||||
|
||||
@@ -6202,7 +6251,8 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
dirCache: mediaDirCache
|
||||
dirCache: mediaDirCache,
|
||||
control
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
@@ -6551,9 +6601,11 @@ class ExportService {
|
||||
lines.push(JSON.stringify({ _type: 'message', ...message }))
|
||||
}
|
||||
this.throwIfStopRequested(control)
|
||||
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
||||
await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8')
|
||||
} else {
|
||||
this.throwIfStopRequested(control)
|
||||
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
||||
await fs.promises.writeFile(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
@@ -6573,6 +6625,9 @@ class ExportService {
|
||||
if (this.isStopError(e)) {
|
||||
return { success: false, error: '导出任务已停止' }
|
||||
}
|
||||
if (this.isPauseError(e)) {
|
||||
return { success: false, error: '导出任务已暂停' }
|
||||
}
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
@@ -6706,7 +6761,8 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
dirCache: mediaDirCache
|
||||
dirCache: mediaDirCache,
|
||||
control
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
@@ -7256,6 +7312,7 @@ class ExportService {
|
||||
}
|
||||
|
||||
this.throwIfStopRequested(control)
|
||||
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
||||
await fs.promises.writeFile(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8')
|
||||
} else {
|
||||
const detailedExport: any = {
|
||||
@@ -7279,6 +7336,7 @@ class ExportService {
|
||||
}
|
||||
|
||||
this.throwIfStopRequested(control)
|
||||
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
||||
await fs.promises.writeFile(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
@@ -7298,6 +7356,9 @@ class ExportService {
|
||||
if (this.isStopError(e)) {
|
||||
return { success: false, error: '导出任务已停止' }
|
||||
}
|
||||
if (this.isPauseError(e)) {
|
||||
return { success: false, error: '导出任务已暂停' }
|
||||
}
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
@@ -7571,7 +7632,8 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
dirCache: mediaDirCache
|
||||
dirCache: mediaDirCache,
|
||||
control
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
@@ -7835,6 +7897,7 @@ class ExportService {
|
||||
|
||||
// 写入文件
|
||||
this.throwIfStopRequested(control)
|
||||
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
||||
await workbook.xlsx.writeFile(outputPath)
|
||||
|
||||
onProgress?.({
|
||||
@@ -7853,6 +7916,9 @@ class ExportService {
|
||||
if (this.isStopError(e)) {
|
||||
return { success: false, error: '导出任务已停止' }
|
||||
}
|
||||
if (this.isPauseError(e)) {
|
||||
return { success: false, error: '导出任务已暂停' }
|
||||
}
|
||||
// 处理文件被占用的错误
|
||||
if (e instanceof Error) {
|
||||
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
|
||||
@@ -8134,6 +8200,9 @@ class ExportService {
|
||||
if (this.isStopError(e)) {
|
||||
return { success: false, error: '导出任务已停止' }
|
||||
}
|
||||
if (this.isPauseError(e)) {
|
||||
return { success: false, error: '导出任务已暂停' }
|
||||
}
|
||||
if (e instanceof Error) {
|
||||
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
|
||||
return { success: false, error: '文件已经打开,请关闭后再导出' }
|
||||
@@ -8315,7 +8384,8 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
dirCache: mediaDirCache
|
||||
dirCache: mediaDirCache,
|
||||
control
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
@@ -8382,6 +8452,7 @@ class ExportService {
|
||||
exportedMessages: 0
|
||||
})
|
||||
|
||||
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
||||
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
||||
const writeChunk = async (chunk: string): Promise<void> => {
|
||||
await new Promise<void>((resolve, _reject) => {
|
||||
@@ -8567,6 +8638,9 @@ class ExportService {
|
||||
if (this.isStopError(e)) {
|
||||
return { success: false, error: '导出任务已停止' }
|
||||
}
|
||||
if (this.isPauseError(e)) {
|
||||
return { success: false, error: '导出任务已暂停' }
|
||||
}
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
@@ -8710,7 +8784,8 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
dirCache: mediaDirCache
|
||||
dirCache: mediaDirCache,
|
||||
control
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
@@ -8777,6 +8852,7 @@ class ExportService {
|
||||
exportedMessages: 0
|
||||
})
|
||||
|
||||
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
||||
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
||||
const writeChunk = async (chunk: string): Promise<void> => {
|
||||
await new Promise<void>((resolve, _reject) => {
|
||||
@@ -8929,6 +9005,9 @@ class ExportService {
|
||||
if (this.isStopError(e)) {
|
||||
return { success: false, error: '导出任务已停止' }
|
||||
}
|
||||
if (this.isPauseError(e)) {
|
||||
return { success: false, error: '导出任务已暂停' }
|
||||
}
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
@@ -9153,7 +9232,8 @@ class ExportService {
|
||||
includeVideoPoster: options.format === 'html',
|
||||
includeVoiceWithTranscript: true,
|
||||
exportVideos: options.exportVideos,
|
||||
dirCache: mediaDirCache
|
||||
dirCache: mediaDirCache,
|
||||
control
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
@@ -9224,7 +9304,8 @@ class ExportService {
|
||||
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl },
|
||||
{ username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
|
||||
],
|
||||
path.dirname(outputPath)
|
||||
path.dirname(outputPath),
|
||||
control
|
||||
)
|
||||
: new Map<string, string>()
|
||||
|
||||
@@ -9241,6 +9322,7 @@ class ExportService {
|
||||
// ================= BEGIN STREAM WRITING =================
|
||||
const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup)
|
||||
const htmlStyles = this.loadExportHtmlStyles()
|
||||
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
||||
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
||||
|
||||
const writePromise = (str: string) => {
|
||||
@@ -9605,6 +9687,9 @@ class ExportService {
|
||||
if (this.isStopError(e)) {
|
||||
return { success: false, error: '导出任务已停止' }
|
||||
}
|
||||
if (this.isPauseError(e)) {
|
||||
return { success: false, error: '导出任务已暂停' }
|
||||
}
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
@@ -9908,7 +9993,7 @@ class ExportService {
|
||||
const reservedOutputPaths = new Set<string>()
|
||||
const ensureTaskDir = async (dirPath: string) => {
|
||||
if (createdTaskDirs.has(dirPath)) return
|
||||
await fs.promises.mkdir(dirPath, { recursive: true })
|
||||
await this.ensureExportDir(dirPath, control)
|
||||
createdTaskDirs.add(dirPath)
|
||||
}
|
||||
await ensureTaskDir(exportBaseDir)
|
||||
@@ -10085,7 +10170,7 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
const runOne = async (sessionId: string): Promise<'done' | 'stopped'> => {
|
||||
const runOne = async (sessionId: string): Promise<'done' | 'stopped' | 'paused'> => {
|
||||
try {
|
||||
this.throwIfStopRequested(control)
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
@@ -10234,6 +10319,10 @@ class ExportService {
|
||||
activeSessionRatios.delete(sessionId)
|
||||
return 'stopped'
|
||||
}
|
||||
if (!result.success && this.isPauseError(result.error)) {
|
||||
activeSessionRatios.delete(sessionId)
|
||||
return 'paused'
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
successCount++
|
||||
@@ -10269,6 +10358,10 @@ class ExportService {
|
||||
activeSessionRatios.delete(sessionId)
|
||||
return 'stopped'
|
||||
}
|
||||
if (this.isPauseError(error)) {
|
||||
activeSessionRatios.delete(sessionId)
|
||||
return 'paused'
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -10294,6 +10387,11 @@ class ExportService {
|
||||
queue.unshift(sessionId)
|
||||
break
|
||||
}
|
||||
if (runState === 'paused') {
|
||||
pauseRequested = true
|
||||
queue.unshift(sessionId)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const workers = Array.from({ length: Math.min(sessionConcurrency, queue.length) }, async () => {
|
||||
@@ -10315,6 +10413,11 @@ class ExportService {
|
||||
queue.unshift(sessionId)
|
||||
break
|
||||
}
|
||||
if (runState === 'paused') {
|
||||
pauseRequested = true
|
||||
queue.unshift(sessionId)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
await Promise.all(workers)
|
||||
@@ -10333,7 +10436,7 @@ class ExportService {
|
||||
sessionOutputPaths
|
||||
}
|
||||
}
|
||||
if (pauseRequested && pendingSessionIds.length > 0) {
|
||||
if (pauseRequested) {
|
||||
return {
|
||||
success: true,
|
||||
successCount,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import * as path from 'path'
|
||||
import { rm, rmdir } from 'fs/promises'
|
||||
|
||||
export type ExportTaskControlState = 'running' | 'pause_requested' | 'cancel_requested'
|
||||
|
||||
export interface ExportTaskControlHooks {
|
||||
shouldPause: () => boolean
|
||||
shouldStop: () => boolean
|
||||
recordCreatedFile: (filePath: string) => void
|
||||
recordCreatedDir: (dirPath: string) => void
|
||||
}
|
||||
|
||||
interface ExportTaskManifest {
|
||||
outputDir: string
|
||||
files: Set<string>
|
||||
dirs: Set<string>
|
||||
}
|
||||
|
||||
interface ExportTaskControlRecord {
|
||||
state: ExportTaskControlState
|
||||
manifest: ExportTaskManifest
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface ExportTaskCleanupResult {
|
||||
success: boolean
|
||||
filesDeleted: number
|
||||
dirsDeleted: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
class ExportTaskControlService {
|
||||
private tasks = new Map<string, ExportTaskControlRecord>()
|
||||
|
||||
createControl(taskId: string, outputDir: string): ExportTaskControlHooks {
|
||||
this.registerTask(taskId, outputDir)
|
||||
return {
|
||||
shouldPause: () => this.getState(taskId) === 'pause_requested',
|
||||
shouldStop: () => this.getState(taskId) === 'cancel_requested',
|
||||
recordCreatedFile: (filePath: string) => this.recordCreatedFile(taskId, filePath),
|
||||
recordCreatedDir: (dirPath: string) => this.recordCreatedDir(taskId, dirPath)
|
||||
}
|
||||
}
|
||||
|
||||
registerTask(taskId: string, outputDir: string): void {
|
||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||
if (!normalizedTaskId) return
|
||||
|
||||
const normalizedOutputDir = path.resolve(String(outputDir || '').trim() || '.')
|
||||
const existing = this.tasks.get(normalizedTaskId)
|
||||
if (existing) {
|
||||
existing.state = 'running'
|
||||
existing.updatedAt = Date.now()
|
||||
if (!existing.manifest.outputDir) {
|
||||
existing.manifest.outputDir = normalizedOutputDir
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.tasks.set(normalizedTaskId, {
|
||||
state: 'running',
|
||||
manifest: {
|
||||
outputDir: normalizedOutputDir,
|
||||
files: new Set<string>(),
|
||||
dirs: new Set<string>()
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
pauseTask(taskId: string): boolean {
|
||||
return this.setState(taskId, 'pause_requested')
|
||||
}
|
||||
|
||||
resumeTask(taskId: string): boolean {
|
||||
return this.setState(taskId, 'running')
|
||||
}
|
||||
|
||||
cancelTask(taskId: string): boolean {
|
||||
return this.setState(taskId, 'cancel_requested')
|
||||
}
|
||||
|
||||
getState(taskId: string): ExportTaskControlState | null {
|
||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||
if (!normalizedTaskId) return null
|
||||
return this.tasks.get(normalizedTaskId)?.state || null
|
||||
}
|
||||
|
||||
releaseTask(taskId: string): void {
|
||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||
if (!normalizedTaskId) return
|
||||
this.tasks.delete(normalizedTaskId)
|
||||
}
|
||||
|
||||
recordCreatedFile(taskId: string, filePath: string): void {
|
||||
const task = this.getTaskForManifestWrite(taskId, filePath)
|
||||
if (!task) return
|
||||
task.manifest.files.add(path.resolve(filePath))
|
||||
task.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
recordCreatedDir(taskId: string, dirPath: string): void {
|
||||
const task = this.getTaskForManifestWrite(taskId, dirPath)
|
||||
if (!task) return
|
||||
task.manifest.dirs.add(path.resolve(dirPath))
|
||||
task.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
async cleanupTask(taskId: string): Promise<ExportTaskCleanupResult> {
|
||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||
const task = normalizedTaskId ? this.tasks.get(normalizedTaskId) : undefined
|
||||
if (!task) {
|
||||
return { success: true, filesDeleted: 0, dirsDeleted: 0 }
|
||||
}
|
||||
|
||||
const outputDir = task.manifest.outputDir
|
||||
let filesDeleted = 0
|
||||
let dirsDeleted = 0
|
||||
const errors: string[] = []
|
||||
|
||||
const files = Array.from(task.manifest.files)
|
||||
.filter(filePath => this.isInsideOutputDir(filePath, outputDir))
|
||||
.sort((a, b) => b.length - a.length)
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
await rm(filePath, { force: true, recursive: false })
|
||||
filesDeleted++
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code
|
||||
if (code !== 'ENOENT') {
|
||||
errors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dirs = Array.from(task.manifest.dirs)
|
||||
.filter(dirPath => this.isInsideOutputDir(dirPath, outputDir) || this.isSamePath(dirPath, outputDir))
|
||||
.sort((a, b) => b.length - a.length)
|
||||
|
||||
for (const dirPath of dirs) {
|
||||
try {
|
||||
await rmdir(dirPath)
|
||||
dirsDeleted++
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code
|
||||
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') {
|
||||
errors.push(`${dirPath}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
this.releaseTask(normalizedTaskId)
|
||||
return { success: true, filesDeleted, dirsDeleted }
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
filesDeleted,
|
||||
dirsDeleted,
|
||||
error: errors.slice(0, 3).join('; ')
|
||||
}
|
||||
}
|
||||
|
||||
private setState(taskId: string, state: ExportTaskControlState): boolean {
|
||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||
if (!normalizedTaskId) return false
|
||||
const task = this.tasks.get(normalizedTaskId)
|
||||
if (!task) return false
|
||||
task.state = state
|
||||
task.updatedAt = Date.now()
|
||||
return true
|
||||
}
|
||||
|
||||
private getTaskForManifestWrite(taskId: string, targetPath: string): ExportTaskControlRecord | null {
|
||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||
if (!normalizedTaskId) return null
|
||||
const task = this.tasks.get(normalizedTaskId)
|
||||
if (!task) return null
|
||||
if (!this.isInsideOutputDir(targetPath, task.manifest.outputDir) && !this.isSamePath(targetPath, task.manifest.outputDir)) {
|
||||
return null
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
private isInsideOutputDir(targetPath: string, outputDir: string): boolean {
|
||||
const resolvedTarget = path.resolve(targetPath)
|
||||
const resolvedOutputDir = path.resolve(outputDir)
|
||||
const relativePath = path.relative(resolvedOutputDir, resolvedTarget)
|
||||
return Boolean(relativePath) && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
|
||||
}
|
||||
|
||||
private isSamePath(left: string, right: string): boolean {
|
||||
const resolvedLeft = path.resolve(left)
|
||||
const resolvedRight = path.resolve(right)
|
||||
if (process.platform === 'win32') {
|
||||
return resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
|
||||
}
|
||||
return resolvedLeft === resolvedRight
|
||||
}
|
||||
|
||||
private normalizeTaskId(taskId: string): string {
|
||||
return String(taskId || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
export const exportTaskControlService = new ExportTaskControlService()
|
||||
@@ -26,7 +26,7 @@ interface ChatLabHeader {
|
||||
interface ChatLabMeta {
|
||||
name: string
|
||||
platform: string
|
||||
type: 'group' | 'private'
|
||||
type: ApiSessionType
|
||||
groupId?: string
|
||||
groupAvatar?: string
|
||||
ownerId?: string
|
||||
@@ -68,6 +68,7 @@ interface ApiMediaOptions {
|
||||
}
|
||||
|
||||
type MediaKind = 'image' | 'voice' | 'video' | 'emoji'
|
||||
type ApiSessionType = 'group' | 'private' | 'channel' | 'other'
|
||||
|
||||
interface ApiExportedMedia {
|
||||
kind: MediaKind
|
||||
@@ -781,6 +782,17 @@ class HttpService {
|
||||
}
|
||||
}
|
||||
|
||||
private getApiSessionType(username: string): ApiSessionType {
|
||||
const normalized = String(username || '').trim()
|
||||
const lowered = normalized.toLowerCase()
|
||||
if (!normalized) return 'other'
|
||||
if (lowered.endsWith('@chatroom')) return 'group'
|
||||
if (lowered.startsWith('gh_')) return 'channel'
|
||||
if (lowered.includes('@openim')) return 'channel'
|
||||
if (lowered.startsWith('weixin') && lowered !== 'weixin') return 'channel'
|
||||
return 'private'
|
||||
}
|
||||
|
||||
private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const talker = (url.searchParams.get('talker') || '').trim()
|
||||
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
||||
@@ -910,7 +922,7 @@ class HttpService {
|
||||
id: s.username,
|
||||
name: s.displayName || s.username,
|
||||
platform: 'wechat',
|
||||
type: s.username.endsWith('@chatroom') ? 'group' : 'private',
|
||||
type: this.getApiSessionType(s.username),
|
||||
messageCount: s.messageCountHint || undefined,
|
||||
lastMessageAt: s.lastTimestamp
|
||||
}))
|
||||
@@ -925,6 +937,7 @@ class HttpService {
|
||||
username: s.username,
|
||||
displayName: s.displayName,
|
||||
type: s.type,
|
||||
sessionType: this.getApiSessionType(s.username),
|
||||
lastTimestamp: s.lastTimestamp,
|
||||
unreadCount: s.unreadCount
|
||||
}))
|
||||
@@ -1532,7 +1545,7 @@ class HttpService {
|
||||
talker,
|
||||
String(msg.localId),
|
||||
msg.createTime || undefined,
|
||||
msg.serverId || undefined
|
||||
this.getMessageServerId(msg) || undefined
|
||||
)
|
||||
if (result.success && result.data) {
|
||||
const fileName = `voice_${msg.localId}.wav`
|
||||
@@ -1586,9 +1599,11 @@ class HttpService {
|
||||
}
|
||||
|
||||
private toApiMessage(msg: Message, media?: ApiExportedMedia): Record<string, any> {
|
||||
const serverId = this.getMessageServerId(msg)
|
||||
|
||||
return {
|
||||
localId: msg.localId,
|
||||
serverId: msg.serverId,
|
||||
serverId: serverId || '0',
|
||||
localType: msg.localType,
|
||||
createTime: msg.createTime,
|
||||
sortSeq: msg.sortSeq,
|
||||
@@ -1604,6 +1619,27 @@ class HttpService {
|
||||
}
|
||||
}
|
||||
|
||||
private getMessageServerId(msg: Message): string {
|
||||
const raw = this.normalizeUnsignedIntToken(msg.serverIdRaw)
|
||||
if (raw && raw !== '0') return raw
|
||||
|
||||
const fallback = this.normalizeUnsignedIntToken(msg.serverId)
|
||||
return fallback && fallback !== '0' ? fallback : ''
|
||||
}
|
||||
|
||||
private normalizeUnsignedIntToken(value: unknown): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
const text = String(value).trim()
|
||||
if (!text) return ''
|
||||
if (/^\d+$/.test(text)) {
|
||||
return text.replace(/^0+(?=\d)/, '')
|
||||
}
|
||||
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return ''
|
||||
return String(Math.floor(numeric))
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析时间参数
|
||||
* 支持 YYYYMMDD 格式,返回秒级时间戳
|
||||
@@ -1868,7 +1904,7 @@ class HttpService {
|
||||
timestamp: msg.createTime,
|
||||
type: this.mapMessageType(msg.localType, msg),
|
||||
content: this.getMessageContent(msg),
|
||||
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
|
||||
platformMessageId: this.getMessageServerId(msg) || undefined,
|
||||
mediaPath: mediaMap.get(msg.localId) ? `http://${this.host}:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
|
||||
}
|
||||
})
|
||||
@@ -1882,7 +1918,7 @@ class HttpService {
|
||||
meta: {
|
||||
name: talkerName,
|
||||
platform: 'wechat',
|
||||
type: isGroup ? 'group' : 'private',
|
||||
type: this.getApiSessionType(talkerId),
|
||||
groupId: isGroup ? talkerId : undefined,
|
||||
groupAvatar: isGroup ? sessionAvatarInfo?.avatarUrl : undefined,
|
||||
ownerId: myWxid || undefined
|
||||
@@ -2045,6 +2081,12 @@ class HttpService {
|
||||
* 获取消息内容
|
||||
*/
|
||||
private getMessageContent(msg: Message): string | null {
|
||||
const normalizeTextContent = (value: string | null | undefined): string | null => {
|
||||
const text = String(value || '')
|
||||
if (!text) return null
|
||||
return text.replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|<br\s*\/?>)\s*|\s*)/i, '').trim()
|
||||
}
|
||||
|
||||
if (msg.localType === 49) {
|
||||
return this.getType49Content(msg)
|
||||
}
|
||||
@@ -2057,7 +2099,7 @@ class HttpService {
|
||||
// 根据类型返回占位符
|
||||
switch (msg.localType) {
|
||||
case 1:
|
||||
return msg.rawContent || null
|
||||
return normalizeTextContent(msg.parsedContent || msg.rawContent)
|
||||
case 3:
|
||||
return '[图片]'
|
||||
case 34:
|
||||
@@ -2073,7 +2115,7 @@ class HttpService {
|
||||
case 49:
|
||||
return this.getType49Content(msg)
|
||||
default:
|
||||
return msg.rawContent || null
|
||||
return normalizeTextContent(msg.parsedContent || msg.rawContent) || null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ export class KeyServiceLinux {
|
||||
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
|
||||
return await this.getDbKey(pid, onStatus)
|
||||
return await this.getDbKey(pid, onStatus, timeoutMs)
|
||||
} catch (err: any) {
|
||||
console.error('[Debug] 自动获取流程彻底崩溃:', err);
|
||||
const errMsg = '自动获取微信 PID 失败: ' + err.message
|
||||
@@ -176,7 +176,7 @@ export class KeyServiceLinux {
|
||||
}
|
||||
}
|
||||
|
||||
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void): Promise<DbKeyResult> {
|
||||
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void, timeoutMs = 180_000): Promise<DbKeyResult> {
|
||||
try {
|
||||
const helperPath = this.getHelperPath()
|
||||
|
||||
@@ -193,29 +193,63 @@ export class KeyServiceLinux {
|
||||
const targetAddr = scanRes.target_addr
|
||||
onStatus?.('基址扫描成功,正在请求管理员权限进行内存 Hook...', 0)
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const options = { name: 'WeFlow' }
|
||||
const command = `"${helperPath}" db_hook ${pid} ${targetAddr}`
|
||||
if (!this.sudo || typeof this.sudo.exec !== 'function') {
|
||||
const err = 'Linux 授权组件 @vscode/sudo-prompt 未加载,请确认依赖已安装并重新启动 WeFlow'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
|
||||
this.sudo.exec(command, options, (error, stdout) => {
|
||||
return await new Promise((resolve) => {
|
||||
const options = {
|
||||
name: 'WeFlow',
|
||||
env: {
|
||||
PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin`
|
||||
}
|
||||
}
|
||||
const timeoutSec = Math.ceil((timeoutMs + 15_000) / 1000)
|
||||
const command = `timeout -k 5s ${timeoutSec}s "${helperPath}" db_hook ${pid} ${targetAddr} ${timeoutMs}`
|
||||
let settled = false
|
||||
const finish = (result: DbKeyResult) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(watchdog)
|
||||
resolve(result)
|
||||
}
|
||||
const watchdog = setTimeout(() => {
|
||||
execAsync(`kill -CONT ${pid}`).catch(() => {})
|
||||
const err = `Hook 等待超时(${Math.round(timeoutMs / 1000)} 秒)。请确认微信登录确认已完成,或重启微信后重试。`
|
||||
onStatus?.(err, 2)
|
||||
finish({ success: false, error: err })
|
||||
}, timeoutMs + 30_000)
|
||||
|
||||
onStatus?.('授权通过后请在手机上确认登录微信,正在等待密钥回调...', 0)
|
||||
|
||||
this.sudo.exec(command, options, (error, stdout, stderr) => {
|
||||
execAsync(`kill -CONT ${pid}`).catch(() => {})
|
||||
if (error) {
|
||||
onStatus?.('授权失败或被取消', 2)
|
||||
resolve({ success: false, error: `授权失败或被取消: ${error.message}` })
|
||||
const detail = String(stderr || '').trim()
|
||||
const message = detail ? `${error.message}: ${detail}` : error.message
|
||||
onStatus?.('授权失败或 Hook 执行失败', 2)
|
||||
finish({ success: false, error: `授权失败或 Hook 执行失败: ${message}` })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const hookRes = JSON.parse((stdout as string).trim())
|
||||
const output = String(stdout || '').trim()
|
||||
if (!output) {
|
||||
const detail = String(stderr || '').trim()
|
||||
throw new Error(detail ? `Hook 无输出: ${detail}` : 'Hook 无输出')
|
||||
}
|
||||
const hookRes = JSON.parse(output)
|
||||
if (hookRes.success) {
|
||||
onStatus?.('密钥获取成功', 1)
|
||||
resolve({ success: true, key: hookRes.key })
|
||||
finish({ success: true, key: hookRes.key })
|
||||
} else {
|
||||
onStatus?.(hookRes.result, 2)
|
||||
resolve({ success: false, error: hookRes.result })
|
||||
finish({ success: false, error: hookRes.result })
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
onStatus?.('解析 Hook 结果失败', 2)
|
||||
resolve({ success: false, error: '解析 Hook 结果失败' })
|
||||
finish({ success: false, error: e?.message || '解析 Hook 结果失败' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -707,7 +707,7 @@ export class KeyServiceMac {
|
||||
}
|
||||
if (code === 'HOOK_FAILED') {
|
||||
if (normalizedDetail.includes('HOOK_TIMEOUT')) {
|
||||
return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。'
|
||||
return 'Hook 已安装,但在等待时间内未触发登录流程。请退出微信账号后重新登录,或在未登录状态下直接登录微信,完成一次登录流程后重试。'
|
||||
}
|
||||
if (normalizedDetail.includes('attach_wait_timeout')) {
|
||||
return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。'
|
||||
|
||||
@@ -1325,13 +1325,19 @@ class MessagePushService {
|
||||
}
|
||||
|
||||
private getMessageDisplayContent(message: Message): string | null {
|
||||
const normalizeTextContent = (value: string | null | undefined): string | null => {
|
||||
const text = String(value || '')
|
||||
if (!text) return null
|
||||
return text.replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|<br\s*\/?>)\s*|\s*)/i, '').trim()
|
||||
}
|
||||
|
||||
const cleanOfficialPrefix = (value: string | null): string | null => {
|
||||
if (!value) return value
|
||||
return value.replace(/^\s*\[视频号\]\s*/u, '').trim() || value
|
||||
}
|
||||
switch (Number(message.localType || 0)) {
|
||||
case 1:
|
||||
return cleanOfficialPrefix(message.rawContent || null)
|
||||
return cleanOfficialPrefix(normalizeTextContent(message.parsedContent || message.rawContent))
|
||||
case 3:
|
||||
return '[图片]'
|
||||
case 34:
|
||||
@@ -1347,7 +1353,7 @@ class MessagePushService {
|
||||
case 49:
|
||||
return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]')
|
||||
default:
|
||||
return cleanOfficialPrefix(message.parsedContent || message.rawContent || null)
|
||||
return cleanOfficialPrefix(normalizeTextContent(message.parsedContent || message.rawContent) || null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,30 @@ type NativeDecryptResult = {
|
||||
ext: string
|
||||
isWxgf?: boolean
|
||||
is_wxgf?: boolean
|
||||
version?: number
|
||||
aesSize?: number
|
||||
aes_size?: number
|
||||
xorSize?: number
|
||||
xor_size?: number
|
||||
rawSize?: number
|
||||
raw_size?: number
|
||||
flag?: number
|
||||
}
|
||||
|
||||
export type NativeDatMeta = {
|
||||
version?: number
|
||||
aesSize?: number
|
||||
aes_size?: number
|
||||
xorSize?: number
|
||||
xor_size?: number
|
||||
rawSize?: number
|
||||
raw_size?: number
|
||||
flag?: number
|
||||
}
|
||||
|
||||
type NativeAddon = {
|
||||
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
|
||||
encryptDatNative?: (inputPath: string, xorKey: number, aesKey?: string, meta?: NativeDatMeta) => Buffer
|
||||
}
|
||||
|
||||
let cachedAddon: NativeAddon | null | undefined
|
||||
@@ -91,7 +111,7 @@ export function decryptDatViaNative(
|
||||
inputPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string
|
||||
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||
): { data: Buffer; ext: string; isWxgf: boolean; meta: NativeDatMeta } | null {
|
||||
const addon = loadAddon()
|
||||
if (!addon) return null
|
||||
|
||||
@@ -103,7 +123,31 @@ export function decryptDatViaNative(
|
||||
? result.ext.trim().toLowerCase()
|
||||
: ''
|
||||
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
|
||||
return { data: result.data, ext, isWxgf }
|
||||
const meta: NativeDatMeta = {
|
||||
version: result.version,
|
||||
aes_size: result.aes_size ?? result.aesSize,
|
||||
xor_size: result.xor_size ?? result.xorSize,
|
||||
raw_size: result.raw_size ?? result.rawSize,
|
||||
flag: result.flag
|
||||
}
|
||||
return { data: result.data, ext, isWxgf, meta }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function encryptDatViaNative(
|
||||
inputPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string,
|
||||
meta?: NativeDatMeta
|
||||
): Buffer | null {
|
||||
const addon = loadAddon()
|
||||
if (!addon || typeof addon.encryptDatNative !== 'function') return null
|
||||
|
||||
try {
|
||||
const result = addon.encryptDatNative(inputPath, xorKey, aesKey, meta)
|
||||
return Buffer.isBuffer(result) ? result : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1340,6 +1340,8 @@ class SnsService {
|
||||
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: {
|
||||
shouldPause?: () => boolean
|
||||
shouldStop?: () => boolean
|
||||
recordCreatedFile?: (filePath: string) => void
|
||||
recordCreatedDir?: (dirPath: string) => void
|
||||
}): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> {
|
||||
const { outputDir, format, usernames, keyword, startTime, endTime } = options
|
||||
const hasExplicitMediaSelection =
|
||||
@@ -1361,6 +1363,18 @@ class SnsService {
|
||||
if (control?.shouldPause?.()) return 'paused'
|
||||
return null
|
||||
}
|
||||
const ensureExportDir = (dirPath: string) => {
|
||||
const existed = existsSync(dirPath)
|
||||
if (!existed) {
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
control?.recordCreatedDir?.(dirPath)
|
||||
}
|
||||
}
|
||||
const recordCreatedFileBeforeWrite = (filePath: string) => {
|
||||
if (!existsSync(filePath)) {
|
||||
control?.recordCreatedFile?.(filePath)
|
||||
}
|
||||
}
|
||||
const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => (
|
||||
state === 'stopped'
|
||||
? { success: true, stopped: true, filePath: '', postCount, mediaCount }
|
||||
@@ -1369,9 +1383,7 @@ class SnsService {
|
||||
|
||||
try {
|
||||
// 确保输出目录存在
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
ensureExportDir(outputDir)
|
||||
|
||||
// 1. 分页加载全部帖子
|
||||
const allPosts: SnsPost[] = []
|
||||
@@ -1414,9 +1426,7 @@ class SnsService {
|
||||
const mediaDir = join(outputDir, 'media')
|
||||
|
||||
if (shouldExportMedia) {
|
||||
if (!existsSync(mediaDir)) {
|
||||
mkdirSync(mediaDir, { recursive: true })
|
||||
}
|
||||
ensureExportDir(mediaDir)
|
||||
|
||||
// 收集所有媒体下载任务
|
||||
const mediaTasks: Array<{
|
||||
@@ -1485,6 +1495,7 @@ class SnsService {
|
||||
} else {
|
||||
const result = await this.fetchAndDecryptImage(task.url, task.key)
|
||||
if (result.success && result.data) {
|
||||
recordCreatedFileBeforeWrite(filePath)
|
||||
await writeFile(filePath, result.data)
|
||||
if (task.kind === 'livephoto') {
|
||||
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
||||
@@ -1494,6 +1505,7 @@ class SnsService {
|
||||
mediaCount++
|
||||
} else if (result.success && result.cachePath) {
|
||||
const cachedData = await readFile(result.cachePath)
|
||||
recordCreatedFileBeforeWrite(filePath)
|
||||
await writeFile(filePath, cachedData)
|
||||
if (task.kind === 'livephoto') {
|
||||
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
||||
@@ -1531,7 +1543,7 @@ class SnsService {
|
||||
// 2.5 下载头像
|
||||
const avatarMap = new Map<string, string>()
|
||||
if (format === 'html') {
|
||||
if (!existsSync(mediaDir)) mkdirSync(mediaDir, { recursive: true })
|
||||
ensureExportDir(mediaDir)
|
||||
const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()]
|
||||
let avatarDone = 0
|
||||
const avatarQueue = [...uniqueUsers]
|
||||
@@ -1548,6 +1560,7 @@ class SnsService {
|
||||
} else {
|
||||
const result = await this.fetchAndDecryptImage(post.avatarUrl!)
|
||||
if (result.success && result.data) {
|
||||
recordCreatedFileBeforeWrite(filePath)
|
||||
await writeFile(filePath, result.data)
|
||||
avatarMap.set(post.username, `media/${fileName}`)
|
||||
}
|
||||
@@ -1602,6 +1615,7 @@ class SnsService {
|
||||
linkUrl: (p as any).linkUrl
|
||||
}))
|
||||
}
|
||||
recordCreatedFileBeforeWrite(outputFilePath)
|
||||
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
||||
} else if (format === 'arkmejson') {
|
||||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
|
||||
@@ -1689,11 +1703,13 @@ class SnsService {
|
||||
},
|
||||
posts
|
||||
}
|
||||
recordCreatedFileBeforeWrite(outputFilePath)
|
||||
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
||||
} else {
|
||||
// HTML 格式
|
||||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
|
||||
const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap)
|
||||
recordCreatedFileBeforeWrite(outputFilePath)
|
||||
await writeFile(outputFilePath, html, 'utf-8')
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,11 @@ export class WcdbCore {
|
||||
private wcdbGetSnsUsernames: any = null
|
||||
private wcdbGetSnsExportStats: any = null
|
||||
private wcdbGetMessageTableColumns: any = null
|
||||
private wcdbListTables: any = null
|
||||
private wcdbGetTableSchema: any = null
|
||||
private wcdbExportTableSnapshot: any = null
|
||||
private wcdbImportTableSnapshot: any = null
|
||||
private wcdbImportTableSnapshotWithSchema: any = null
|
||||
private wcdbGetMessageTableTimeRange: any = null
|
||||
private wcdbResolveImageHardlink: any = null
|
||||
private wcdbResolveImageHardlinkBatch: any = null
|
||||
@@ -1090,6 +1095,31 @@ export class WcdbCore {
|
||||
} catch {
|
||||
this.wcdbGetMessageTableColumns = null
|
||||
}
|
||||
try {
|
||||
this.wcdbListTables = this.lib.func('int32 wcdb_list_tables(int64 handle, const char* kind, const char* dbPath, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbListTables = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetTableSchema = this.lib.func('int32 wcdb_get_table_schema(int64 handle, const char* kind, const char* dbPath, const char* tableName, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetTableSchema = null
|
||||
}
|
||||
try {
|
||||
this.wcdbExportTableSnapshot = this.lib.func('int32 wcdb_export_table_snapshot(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* outputPath, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbExportTableSnapshot = null
|
||||
}
|
||||
try {
|
||||
this.wcdbImportTableSnapshot = this.lib.func('int32 wcdb_import_table_snapshot(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* inputPath, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbImportTableSnapshot = null
|
||||
}
|
||||
try {
|
||||
this.wcdbImportTableSnapshotWithSchema = this.lib.func('int32 wcdb_import_table_snapshot_with_schema(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* inputPath, const char* createTableSql, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbImportTableSnapshotWithSchema = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetMessageTableTimeRange = this.lib.func('int32 wcdb_get_message_table_time_range(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)')
|
||||
} catch {
|
||||
@@ -2902,6 +2932,96 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
async listTables(kind: string, dbPath: string = ''): Promise<{ success: boolean; tables?: string[]; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbListTables) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbListTables(this.handle, kind, dbPath || '', outPtr)
|
||||
if (result !== 0 || !outPtr[0]) return { success: false, error: `获取表列表失败: ${result}` }
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析表列表失败' }
|
||||
const tables = JSON.parse(jsonStr)
|
||||
return { success: true, tables: Array.isArray(tables) ? tables.map((c: any) => String(c || '')).filter(Boolean) : [] }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getTableSchema(kind: string, dbPath: string, tableName: string): Promise<{ success: boolean; schema?: string; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetTableSchema) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetTableSchema(this.handle, kind, dbPath || '', tableName, outPtr)
|
||||
const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : ''
|
||||
const data = jsonStr ? JSON.parse(jsonStr) : {}
|
||||
if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `获取表结构失败: ${result}` }
|
||||
return { success: true, schema: String(data?.schema || '') }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async exportTableSnapshot(kind: string, dbPath: string, tableName: string, outputPath: string): Promise<{ success: boolean; rows?: number; columns?: number; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbExportTableSnapshot) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbExportTableSnapshot(this.handle, kind, dbPath || '', tableName, outputPath, outPtr)
|
||||
const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : ''
|
||||
const data = jsonStr ? JSON.parse(jsonStr) : {}
|
||||
if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导出表快照失败: ${result}` }
|
||||
return { success: true, rows: Number(data?.rows || 0), columns: Number(data?.columns || 0) }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async importTableSnapshot(kind: string, dbPath: string, tableName: string, inputPath: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbImportTableSnapshot) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbImportTableSnapshot(this.handle, kind, dbPath || '', tableName, inputPath, outPtr)
|
||||
const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : ''
|
||||
const data = jsonStr ? JSON.parse(jsonStr) : {}
|
||||
if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导入表快照失败: ${result}` }
|
||||
return {
|
||||
success: true,
|
||||
rows: Number(data?.rows || 0),
|
||||
inserted: Number(data?.inserted || 0),
|
||||
ignored: Number(data?.ignored || 0),
|
||||
malformed: Number(data?.malformed || 0),
|
||||
columns: Number(data?.columns || 0)
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async importTableSnapshotWithSchema(kind: string, dbPath: string, tableName: string, inputPath: string, createTableSql: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbImportTableSnapshotWithSchema) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbImportTableSnapshotWithSchema(this.handle, kind, dbPath || '', tableName, inputPath, createTableSql || '', outPtr)
|
||||
const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : ''
|
||||
const data = jsonStr ? JSON.parse(jsonStr) : {}
|
||||
if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导入表快照失败: ${result}` }
|
||||
return {
|
||||
success: true,
|
||||
rows: Number(data?.rows || 0),
|
||||
inserted: Number(data?.inserted || 0),
|
||||
ignored: Number(data?.ignored || 0),
|
||||
malformed: Number(data?.malformed || 0),
|
||||
columns: Number(data?.columns || 0)
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetMessageTableTimeRange) return { success: false, error: '接口未就绪' }
|
||||
|
||||
@@ -92,6 +92,9 @@ export class WcdbService {
|
||||
this.setPaths(this.resourcesPath, this.userDataPath)
|
||||
}
|
||||
this.setLogEnabled(this.logEnabled)
|
||||
if (this.monitorListener) {
|
||||
this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { })
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
// Failed to create worker
|
||||
@@ -366,6 +369,26 @@ export class WcdbService {
|
||||
return this.callWorker('getMessageTableColumns', { dbPath, tableName })
|
||||
}
|
||||
|
||||
async listTables(kind: string, dbPath: string = ''): Promise<{ success: boolean; tables?: string[]; error?: string }> {
|
||||
return this.callWorker('listTables', { kind, dbPath })
|
||||
}
|
||||
|
||||
async getTableSchema(kind: string, dbPath: string, tableName: string): Promise<{ success: boolean; schema?: string; error?: string }> {
|
||||
return this.callWorker('getTableSchema', { kind, dbPath, tableName })
|
||||
}
|
||||
|
||||
async exportTableSnapshot(kind: string, dbPath: string, tableName: string, outputPath: string): Promise<{ success: boolean; rows?: number; columns?: number; error?: string }> {
|
||||
return this.callWorker('exportTableSnapshot', { kind, dbPath, tableName, outputPath })
|
||||
}
|
||||
|
||||
async importTableSnapshot(kind: string, dbPath: string, tableName: string, inputPath: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
|
||||
return this.callWorker('importTableSnapshot', { kind, dbPath, tableName, inputPath })
|
||||
}
|
||||
|
||||
async importTableSnapshotWithSchema(kind: string, dbPath: string, tableName: string, inputPath: string, createTableSql: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
|
||||
return this.callWorker('importTableSnapshotWithSchema', { kind, dbPath, tableName, inputPath, createTableSql })
|
||||
}
|
||||
|
||||
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('getMessageTableTimeRange', { dbPath, tableName })
|
||||
}
|
||||
|
||||
@@ -116,6 +116,21 @@ if (parentPort) {
|
||||
case 'getMessageTableColumns':
|
||||
result = await core.getMessageTableColumns(payload.dbPath, payload.tableName)
|
||||
break
|
||||
case 'listTables':
|
||||
result = await core.listTables(payload.kind, payload.dbPath)
|
||||
break
|
||||
case 'getTableSchema':
|
||||
result = await core.getTableSchema(payload.kind, payload.dbPath, payload.tableName)
|
||||
break
|
||||
case 'exportTableSnapshot':
|
||||
result = await core.exportTableSnapshot(payload.kind, payload.dbPath, payload.tableName, payload.outputPath)
|
||||
break
|
||||
case 'importTableSnapshot':
|
||||
result = await core.importTableSnapshot(payload.kind, payload.dbPath, payload.tableName, payload.inputPath)
|
||||
break
|
||||
case 'importTableSnapshotWithSchema':
|
||||
result = await core.importTableSnapshotWithSchema(payload.kind, payload.dbPath, payload.tableName, payload.inputPath, payload.createTableSql)
|
||||
break
|
||||
case 'getMessageTableTimeRange':
|
||||
result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName)
|
||||
break
|
||||
|
||||
Generated
+7
-8
@@ -9,6 +9,7 @@
|
||||
"version": "4.3.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@vscode/sudo-prompt": "^9.3.2",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^11.0.2",
|
||||
@@ -29,7 +30,6 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
@@ -3050,6 +3050,12 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/sudo-prompt": {
|
||||
"version": "9.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz",
|
||||
"integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.12",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
|
||||
@@ -9456,13 +9462,6 @@
|
||||
"inline-style-parser": "0.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/sudo-prompt": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz",
|
||||
"integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sumchecker": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"@vscode/sudo-prompt": "^9.3.2",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -27,6 +27,7 @@ import ResourcesPage from './pages/ResourcesPage'
|
||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||
import NotificationWindow from './pages/NotificationWindow'
|
||||
import AccountManagementPage from './pages/AccountManagementPage'
|
||||
import BackupPage from './pages/BackupPage'
|
||||
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
|
||||
@@ -705,6 +706,7 @@ function App() {
|
||||
<Route path="/biz" element={<BizPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
<Route path="/backup" element={<BackupPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users } from 'lucide-react'
|
||||
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users, ArchiveRestore } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import * as configService from '../services/config'
|
||||
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||
@@ -412,6 +412,15 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/backup"
|
||||
className={`nav-item ${isActive('/backup') ? 'active' : ''}`}
|
||||
title={collapsed ? '数据库备份' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><ArchiveRestore size={20} /></span>
|
||||
<span className="nav-label">数据库备份</span>
|
||||
</NavLink>
|
||||
|
||||
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
.backup-page {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.backup-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.resource-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: -8px 0 18px;
|
||||
|
||||
label {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.primary-btn,
|
||||
.secondary-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.backup-status-band {
|
||||
min-height: 88px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.status-detail {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
margin-top: 12px;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.backup-summary,
|
||||
.restore-result {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.summary-item,
|
||||
.restore-result > div {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 14px;
|
||||
min-height: 74px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 20px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
}
|
||||
|
||||
.backup-detail {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.detail-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
div {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.db-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.db-row {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 80px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 9px 0;
|
||||
font-size: 13px;
|
||||
|
||||
span {
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
em {
|
||||
color: var(--text-secondary);
|
||||
font-style: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.backup-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.backup-summary,
|
||||
.restore-result,
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.db-row {
|
||||
grid-template-columns: 82px 64px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ArchiveRestore, Database, Download, File, FileArchive, Image, Upload, Video } from 'lucide-react'
|
||||
import './BackupPage.scss'
|
||||
|
||||
type BackupManifest = NonNullable<Awaited<ReturnType<typeof window.electronAPI.backup.inspect>>['manifest']>
|
||||
type BackupProgress = Parameters<Parameters<typeof window.electronAPI.backup.onProgress>[0]>[0]
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return '-'
|
||||
try {
|
||||
return new Date(value).toLocaleString()
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeManifest(manifest?: BackupManifest | null) {
|
||||
if (!manifest) return { dbCount: 0, tableCount: 0, rowCount: 0, resourceCount: 0 }
|
||||
let tableCount = 0
|
||||
let rowCount = 0
|
||||
for (const db of manifest.databases || []) {
|
||||
tableCount += db.tables?.length || 0
|
||||
rowCount += (db.tables || []).reduce((sum, table) => sum + (table.rows || 0), 0)
|
||||
}
|
||||
const resourceCount =
|
||||
(manifest.resources?.images?.length || 0) +
|
||||
(manifest.resources?.videos?.length || 0) +
|
||||
(manifest.resources?.files?.length || 0)
|
||||
return { dbCount: manifest.databases?.length || 0, tableCount, rowCount, resourceCount }
|
||||
}
|
||||
|
||||
function BackupPage() {
|
||||
const [progress, setProgress] = useState<BackupProgress | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [selectedArchive, setSelectedArchive] = useState('')
|
||||
const [manifest, setManifest] = useState<BackupManifest | null>(null)
|
||||
const [restoreSummary, setRestoreSummary] = useState<{ inserted: number; ignored: number; skipped: number } | null>(null)
|
||||
const [resourceOptions, setResourceOptions] = useState({
|
||||
includeImages: false,
|
||||
includeVideos: false,
|
||||
includeFiles: false
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return window.electronAPI.backup.onProgress(setProgress)
|
||||
}, [])
|
||||
|
||||
const summary = useMemo(() => summarizeManifest(manifest), [manifest])
|
||||
const percent = progress?.total && progress.total > 0
|
||||
? Math.min(100, Math.round(((progress.current || 0) / progress.total) * 100))
|
||||
: (busy ? 8 : 0)
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
setProgress(null)
|
||||
setMessage('')
|
||||
setRestoreSummary(null)
|
||||
try {
|
||||
const hasResources = resourceOptions.includeImages || resourceOptions.includeVideos || resourceOptions.includeFiles
|
||||
const extension = hasResources ? 'tar' : 'tar.gz'
|
||||
const defaultPath = `weflow-db-backup-${new Date().toISOString().slice(0, 10)}.${extension}`
|
||||
const result = await window.electronAPI.dialog.saveFile({
|
||||
title: '保存数据库备份',
|
||||
defaultPath,
|
||||
filters: [{ name: 'WeFlow 数据库备份', extensions: hasResources ? ['tar'] : ['gz'] }]
|
||||
})
|
||||
if (result.canceled || !result.filePath) {
|
||||
setMessage('已取消')
|
||||
return
|
||||
}
|
||||
const created = await window.electronAPI.backup.create({
|
||||
outputPath: result.filePath,
|
||||
options: resourceOptions
|
||||
})
|
||||
if (!created.success) {
|
||||
setProgress(null)
|
||||
setMessage(created.error || '备份失败')
|
||||
return
|
||||
}
|
||||
setSelectedArchive(created.filePath || result.filePath)
|
||||
setManifest(created.manifest || null)
|
||||
setMessage('备份完成')
|
||||
} catch (error) {
|
||||
setProgress(null)
|
||||
setMessage(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePickArchive = async () => {
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
setProgress(null)
|
||||
setMessage('')
|
||||
setRestoreSummary(null)
|
||||
try {
|
||||
const result = await window.electronAPI.dialog.openFile({
|
||||
title: '选择数据库备份',
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: 'WeFlow 数据库备份', extensions: ['tar', 'gz', 'tgz'] },
|
||||
{ name: '所有文件', extensions: ['*'] }
|
||||
]
|
||||
})
|
||||
if (result.canceled || !result.filePaths?.[0]) {
|
||||
setMessage('已取消')
|
||||
return
|
||||
}
|
||||
const archivePath = result.filePaths[0]
|
||||
const inspected = await window.electronAPI.backup.inspect({ archivePath })
|
||||
if (!inspected.success) {
|
||||
setProgress(null)
|
||||
setMessage(inspected.error || '读取备份失败')
|
||||
return
|
||||
}
|
||||
setSelectedArchive(archivePath)
|
||||
setManifest(inspected.manifest || null)
|
||||
setMessage('备份包已读取')
|
||||
} catch (error) {
|
||||
setProgress(null)
|
||||
setMessage(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (busy || !selectedArchive) return
|
||||
setBusy(true)
|
||||
setProgress(null)
|
||||
setMessage('')
|
||||
setRestoreSummary(null)
|
||||
try {
|
||||
const restored = await window.electronAPI.backup.restore({ archivePath: selectedArchive })
|
||||
if (!restored.success) {
|
||||
setProgress(null)
|
||||
setMessage(restored.error || '载入失败')
|
||||
return
|
||||
}
|
||||
setRestoreSummary({
|
||||
inserted: restored.inserted || 0,
|
||||
ignored: restored.ignored || 0,
|
||||
skipped: restored.skipped || 0
|
||||
})
|
||||
setMessage('载入完成')
|
||||
} catch (error) {
|
||||
setProgress(null)
|
||||
setMessage(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="backup-page">
|
||||
<div className="backup-header">
|
||||
<div>
|
||||
<h1>数据库备份</h1>
|
||||
<p>Snapshots 增量备份与载入</p>
|
||||
</div>
|
||||
<div className="backup-actions">
|
||||
<button className="primary-btn" onClick={handleCreateBackup} disabled={busy}>
|
||||
<Download size={16} />
|
||||
<span>创建备份</span>
|
||||
</button>
|
||||
<button className="secondary-btn" onClick={handlePickArchive} disabled={busy}>
|
||||
<FileArchive size={16} />
|
||||
<span>选择备份</span>
|
||||
</button>
|
||||
<button className="secondary-btn" onClick={handleRestore} disabled={busy || !selectedArchive}>
|
||||
<Upload size={16} />
|
||||
<span>载入</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="resource-options" aria-label="资源备份选项">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={resourceOptions.includeImages}
|
||||
disabled={busy}
|
||||
onChange={(event) => setResourceOptions(prev => ({ ...prev, includeImages: event.target.checked }))}
|
||||
/>
|
||||
<Image size={16} />
|
||||
<span>图片</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={resourceOptions.includeVideos}
|
||||
disabled={busy}
|
||||
onChange={(event) => setResourceOptions(prev => ({ ...prev, includeVideos: event.target.checked }))}
|
||||
/>
|
||||
<Video size={16} />
|
||||
<span>视频</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={resourceOptions.includeFiles}
|
||||
disabled={busy}
|
||||
onChange={(event) => setResourceOptions(prev => ({ ...prev, includeFiles: event.target.checked }))}
|
||||
/>
|
||||
<File size={16} />
|
||||
<span>文件</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div className="backup-status-band">
|
||||
<div className="status-icon">
|
||||
<ArchiveRestore size={22} />
|
||||
</div>
|
||||
<div className="status-body">
|
||||
<div className="status-title">{progress?.message || message || '等待操作'}</div>
|
||||
<div className="status-detail">{progress?.detail || selectedArchive || '未选择备份包'}</div>
|
||||
{busy && (
|
||||
<div className="progress-track">
|
||||
<div className="progress-fill" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="backup-summary">
|
||||
<div className="summary-item">
|
||||
<Database size={18} />
|
||||
<span>数据库</span>
|
||||
<strong>{summary.dbCount}</strong>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<Database size={18} />
|
||||
<span>表</span>
|
||||
<strong>{summary.tableCount}</strong>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<Database size={18} />
|
||||
<span>行</span>
|
||||
<strong>{summary.rowCount.toLocaleString()}</strong>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<FileArchive size={18} />
|
||||
<span>资源</span>
|
||||
<strong>{summary.resourceCount.toLocaleString()}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{manifest && (
|
||||
<section className="backup-detail">
|
||||
<div className="detail-heading">
|
||||
<h2>备份信息</h2>
|
||||
<span>{formatDate(manifest.createdAt)}</span>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div>
|
||||
<span>来源账号</span>
|
||||
<strong>{manifest.source.wxid || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>版本</span>
|
||||
<strong>{manifest.appVersion || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>资源</span>
|
||||
<strong>
|
||||
图片 {manifest.resources?.images?.length || 0} / 视频 {manifest.resources?.videos?.length || 0} / 文件 {manifest.resources?.files?.length || 0}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="db-list">
|
||||
{manifest.databases.map(db => (
|
||||
<div className="db-row" key={db.id}>
|
||||
<span>{db.kind}</span>
|
||||
<strong>{db.tables.length} 表</strong>
|
||||
<em>{db.relativePath}</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{restoreSummary && (
|
||||
<section className="restore-result">
|
||||
<div>
|
||||
<span>新增</span>
|
||||
<strong>{restoreSummary.inserted.toLocaleString()}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>已存在</span>
|
||||
<strong>{restoreSummary.ignored.toLocaleString()}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>跳过</span>
|
||||
<strong>{restoreSummary.skipped.toLocaleString()}</strong>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BackupPage
|
||||
+253
-18
@@ -80,7 +80,7 @@ import {
|
||||
import './ExportPage.scss'
|
||||
|
||||
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
|
||||
type TaskStatus = 'queued' | 'running' | 'success' | 'error'
|
||||
type TaskStatus = 'queued' | 'running' | 'pause_requested' | 'paused' | 'cancel_requested' | 'success' | 'error'
|
||||
type TaskScope = 'single' | 'multi' | 'content' | 'sns'
|
||||
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
|
||||
type ContentCardType = ContentType | 'sns'
|
||||
@@ -578,10 +578,27 @@ const formatDurationMs = (ms: number): string => {
|
||||
const getTaskStatusLabel = (task: ExportTask): string => {
|
||||
if (task.status === 'queued') return '排队中'
|
||||
if (task.status === 'running') return '进行中'
|
||||
if (task.status === 'pause_requested') return '暂停中'
|
||||
if (task.status === 'paused') return '已暂停'
|
||||
if (task.status === 'cancel_requested') return '取消中'
|
||||
if (task.status === 'success') return '已完成'
|
||||
return '失败'
|
||||
}
|
||||
|
||||
const resolveExportTaskCardClass = (status: TaskStatus): 'queued' | 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
|
||||
if (status === 'pause_requested' || status === 'paused') return 'paused'
|
||||
if (status === 'cancel_requested') return 'stopped'
|
||||
return status
|
||||
}
|
||||
|
||||
const isExportTaskActiveStatus = (status: TaskStatus): boolean => (
|
||||
status === 'queued' ||
|
||||
status === 'running' ||
|
||||
status === 'pause_requested' ||
|
||||
status === 'paused' ||
|
||||
status === 'cancel_requested'
|
||||
)
|
||||
|
||||
const resolveBackgroundTaskCardClass = (status: BackgroundTaskRecord['status']): 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
|
||||
if (status === 'running') return 'running'
|
||||
if (status === 'pause_requested' || status === 'paused') return 'paused'
|
||||
@@ -1809,6 +1826,9 @@ interface TaskCenterModalProps {
|
||||
nowTick: number
|
||||
onClose: () => void
|
||||
onTogglePerfTask: (taskId: string) => void
|
||||
onPauseExportTask: (taskId: string) => void
|
||||
onResumeExportTask: (taskId: string) => void
|
||||
onCancelExportTask: (taskId: string) => void
|
||||
onPauseBackgroundTask: (taskId: string) => void
|
||||
onResumeBackgroundTask: (taskId: string) => void
|
||||
onCancelBackgroundTask: (taskId: string) => void
|
||||
@@ -1824,6 +1844,9 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
nowTick,
|
||||
onClose,
|
||||
onTogglePerfTask,
|
||||
onPauseExportTask,
|
||||
onResumeExportTask,
|
||||
onCancelExportTask,
|
||||
onPauseBackgroundTask,
|
||||
onResumeBackgroundTask,
|
||||
onCancelBackgroundTask
|
||||
@@ -1954,15 +1977,31 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
: `图片耗时 ${formatDurationMs(imageTimingElapsedMs)}`
|
||||
)
|
||||
: ''
|
||||
const taskCardClass = resolveExportTaskCardClass(task.status)
|
||||
const canShowProgress = (
|
||||
task.status === 'running' ||
|
||||
task.status === 'pause_requested' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'cancel_requested'
|
||||
)
|
||||
const canPause = task.status === 'running'
|
||||
const canResume = task.status === 'paused' || task.status === 'pause_requested'
|
||||
const canCancel = (
|
||||
task.status === 'queued' ||
|
||||
task.status === 'running' ||
|
||||
task.status === 'pause_requested' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'cancel_requested'
|
||||
)
|
||||
return (
|
||||
<div key={task.id} className={`task-card ${task.status}`}>
|
||||
<div key={task.id} className={`task-card ${taskCardClass}`}>
|
||||
<div className="task-main">
|
||||
<div className="task-title">{task.title}</div>
|
||||
<div className="task-meta">
|
||||
<span className={`task-status ${task.status}`}>{getTaskStatusLabel(task)}</span>
|
||||
<span className={`task-status ${taskCardClass}`}>{getTaskStatusLabel(task)}</span>
|
||||
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
{task.status === 'running' && (
|
||||
{canShowProgress && (
|
||||
<>
|
||||
<div className="task-progress-bar">
|
||||
<div
|
||||
@@ -2050,6 +2089,34 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
{isPerfExpanded ? '收起详情' : '性能详情'}
|
||||
</button>
|
||||
)}
|
||||
{canPause && (
|
||||
<button
|
||||
className="task-action-btn"
|
||||
type="button"
|
||||
onClick={() => onPauseExportTask(task.id)}
|
||||
>
|
||||
<Pause size={14} /> 暂停
|
||||
</button>
|
||||
)}
|
||||
{canResume && (
|
||||
<button
|
||||
className="task-action-btn primary"
|
||||
type="button"
|
||||
onClick={() => onResumeExportTask(task.id)}
|
||||
>
|
||||
<Play size={14} /> 继续
|
||||
</button>
|
||||
)}
|
||||
{canCancel && (
|
||||
<button
|
||||
className="task-action-btn danger"
|
||||
type="button"
|
||||
onClick={() => onCancelExportTask(task.id)}
|
||||
disabled={task.status === 'cancel_requested'}
|
||||
>
|
||||
<Square size={14} /> {task.status === 'cancel_requested' ? '取消中' : '取消'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="task-action-btn"
|
||||
onClick={() => {
|
||||
@@ -5586,7 +5653,7 @@ function ExportPage() {
|
||||
const now = Date.now()
|
||||
const currentSessionId = String(payload.currentSessionId || '').trim()
|
||||
updateTask(next.id, task => {
|
||||
if (task.status !== 'running') return task
|
||||
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'cancel_requested') return task
|
||||
const performance = applyProgressToTaskPerformance(task, payload, now)
|
||||
const settledSessionIds = task.settledSessionIds || []
|
||||
const nextSettledSessionIds = (
|
||||
@@ -5740,7 +5807,8 @@ function ExportPage() {
|
||||
exportLivePhotos: snsOptions.exportLivePhotos,
|
||||
exportVideos: snsOptions.exportVideos,
|
||||
startTime: snsOptions.startTime,
|
||||
endTime: snsOptions.endTime
|
||||
endTime: snsOptions.endTime,
|
||||
taskId: next.id
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
@@ -5751,6 +5819,19 @@ function ExportPage() {
|
||||
error: result.error || '朋友圈导出失败',
|
||||
performance: finalizeTaskPerformance(task, Date.now())
|
||||
}))
|
||||
} else if (result.stopped) {
|
||||
setTasks(prev => prev.filter(task => task.id !== next.id))
|
||||
} else if (result.paused) {
|
||||
updateTask(next.id, task => ({
|
||||
...task,
|
||||
status: 'paused',
|
||||
progress: {
|
||||
...task.progress,
|
||||
phaseLabel: '已暂停,可继续或取消',
|
||||
current: Math.max(task.progress.current, result.postCount || 0),
|
||||
total: Math.max(task.progress.total, result.postCount || 0)
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
const doneAt = Date.now()
|
||||
const exportedPosts = Math.max(0, result.postCount || 0)
|
||||
@@ -5782,7 +5863,8 @@ function ExportPage() {
|
||||
const result = await window.electronAPI.export.exportSessions(
|
||||
next.payload.sessionIds,
|
||||
next.payload.outputDir,
|
||||
next.payload.options
|
||||
next.payload.options,
|
||||
{ taskId: next.id }
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
@@ -5793,6 +5875,33 @@ function ExportPage() {
|
||||
error: result.error || '导出失败',
|
||||
performance: finalizeTaskPerformance(task, Date.now())
|
||||
}))
|
||||
} else if (result.stopped) {
|
||||
setTasks(prev => prev.filter(task => task.id !== next.id))
|
||||
} else if (result.paused) {
|
||||
const pendingSessionIds = Array.isArray(result.pendingSessionIds)
|
||||
? result.pendingSessionIds
|
||||
: []
|
||||
updateTask(next.id, task => ({
|
||||
...task,
|
||||
status: 'paused',
|
||||
payload: {
|
||||
...task.payload,
|
||||
sessionIds: pendingSessionIds.length > 0 ? pendingSessionIds : task.payload.sessionIds
|
||||
},
|
||||
settledSessionIds: Array.isArray(result.successSessionIds)
|
||||
? Array.from(new Set([...(task.settledSessionIds || []), ...result.successSessionIds]))
|
||||
: task.settledSessionIds,
|
||||
sessionOutputPaths: {
|
||||
...(task.sessionOutputPaths || {}),
|
||||
...((result.sessionOutputPaths && typeof result.sessionOutputPaths === 'object')
|
||||
? result.sessionOutputPaths
|
||||
: {})
|
||||
},
|
||||
progress: {
|
||||
...task.progress,
|
||||
phaseLabel: '已暂停,可继续或取消'
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
const doneAt = Date.now()
|
||||
const contentTypes = next.payload.contentType
|
||||
@@ -5913,7 +6022,13 @@ function ExportPage() {
|
||||
}
|
||||
|
||||
const hasConflict = tasksRef.current.some((item) => {
|
||||
if (item.status !== 'running' && item.status !== 'queued') return false
|
||||
if (
|
||||
item.status !== 'running' &&
|
||||
item.status !== 'queued' &&
|
||||
item.status !== 'pause_requested' &&
|
||||
item.status !== 'paused' &&
|
||||
item.status !== 'cancel_requested'
|
||||
) return false
|
||||
return item.payload.automationTaskId === task.id
|
||||
})
|
||||
if (hasConflict) {
|
||||
@@ -6200,7 +6315,7 @@ function ExportPage() {
|
||||
const runningSessionIds = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const task of tasks) {
|
||||
if (task.status !== 'running') continue
|
||||
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'cancel_requested') continue
|
||||
const settled = new Set(task.settledSessionIds || [])
|
||||
for (const id of task.payload.sessionIds) {
|
||||
if (settled.has(id)) continue
|
||||
@@ -6213,7 +6328,7 @@ function ExportPage() {
|
||||
const queuedSessionIds = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const task of tasks) {
|
||||
if (task.status !== 'queued') continue
|
||||
if (task.status !== 'queued' && task.status !== 'paused') continue
|
||||
for (const id of task.payload.sessionIds) {
|
||||
set.add(id)
|
||||
}
|
||||
@@ -6224,7 +6339,7 @@ function ExportPage() {
|
||||
const inProgressSessionIds = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const task of tasks) {
|
||||
if (task.status !== 'running' && task.status !== 'queued') continue
|
||||
if (!isExportTaskActiveStatus(task.status)) continue
|
||||
for (const id of task.payload.sessionIds) {
|
||||
set.add(id)
|
||||
}
|
||||
@@ -6232,7 +6347,7 @@ function ExportPage() {
|
||||
return Array.from(set).sort()
|
||||
}, [tasks])
|
||||
const activeTaskCount = useMemo(
|
||||
() => tasks.filter(task => task.status === 'running' || task.status === 'queued').length,
|
||||
() => tasks.filter(task => isExportTaskActiveStatus(task.status)).length,
|
||||
[tasks]
|
||||
)
|
||||
|
||||
@@ -6247,7 +6362,7 @@ function ExportPage() {
|
||||
if (previousStatus === task.status) continue
|
||||
|
||||
const now = Date.now()
|
||||
if (task.status === 'running') {
|
||||
if (task.status === 'running' || task.status === 'pause_requested' || task.status === 'paused' || task.status === 'cancel_requested') {
|
||||
patchAutomationTask(automationTaskId, (current) => ({
|
||||
...current,
|
||||
updatedAt: now,
|
||||
@@ -6338,7 +6453,13 @@ function ExportPage() {
|
||||
if (task.runState?.lastScheduleKey === scheduleKey) continue
|
||||
|
||||
const hasConflict = tasksRef.current.some((item) => {
|
||||
if (item.status !== 'running' && item.status !== 'queued') return false
|
||||
if (
|
||||
item.status !== 'running' &&
|
||||
item.status !== 'queued' &&
|
||||
item.status !== 'pause_requested' &&
|
||||
item.status !== 'paused' &&
|
||||
item.status !== 'cancel_requested'
|
||||
) return false
|
||||
return item.payload.automationTaskId === task.id
|
||||
})
|
||||
if (hasConflict) {
|
||||
@@ -6448,7 +6569,7 @@ function ExportPage() {
|
||||
const runningCardTypes = useMemo(() => {
|
||||
const set = new Set<ContentCardType>()
|
||||
for (const task of tasks) {
|
||||
if (task.status !== 'running') continue
|
||||
if (!isExportTaskActiveStatus(task.status)) continue
|
||||
if (task.payload.scope === 'sns') {
|
||||
set.add('sns')
|
||||
continue
|
||||
@@ -7891,7 +8012,12 @@ function ExportPage() {
|
||||
)
|
||||
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
|
||||
const isSnsCardStatsLoading = !hasSeededSnsStats
|
||||
const taskRunningCount = tasks.filter(task => task.status === 'running').length
|
||||
const taskRunningCount = tasks.filter(task => (
|
||||
task.status === 'running' ||
|
||||
task.status === 'pause_requested' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'cancel_requested'
|
||||
)).length
|
||||
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
||||
const chatBackgroundTasks = useMemo(() => (
|
||||
backgroundTasks.filter(task => task.sourcePage === 'chat')
|
||||
@@ -8105,6 +8231,112 @@ function ExportPage() {
|
||||
const toggleTaskPerfDetail = useCallback((taskId: string) => {
|
||||
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
|
||||
}, [])
|
||||
const handlePauseExportTask = useCallback((taskId: string) => {
|
||||
const task = tasksRef.current.find(item => item.id === taskId)
|
||||
if (!task || task.status !== 'running') return
|
||||
updateTask(taskId, current => ({
|
||||
...current,
|
||||
status: 'pause_requested',
|
||||
progress: {
|
||||
...current.progress,
|
||||
phaseLabel: current.progress.phaseLabel || '暂停请求已发送'
|
||||
}
|
||||
}))
|
||||
window.electronAPI.export.pauseTask(taskId).then(result => {
|
||||
if (result.success) return
|
||||
updateTask(taskId, current => ({
|
||||
...current,
|
||||
status: current.status === 'pause_requested' ? 'running' : current.status,
|
||||
error: result.error || '暂停请求失败'
|
||||
}))
|
||||
}).catch(error => {
|
||||
updateTask(taskId, current => ({
|
||||
...current,
|
||||
status: current.status === 'pause_requested' ? 'running' : current.status,
|
||||
error: String(error)
|
||||
}))
|
||||
})
|
||||
}, [updateTask])
|
||||
const handleResumeExportTask = useCallback((taskId: string) => {
|
||||
const task = tasksRef.current.find(item => item.id === taskId)
|
||||
if (!task || (task.status !== 'paused' && task.status !== 'pause_requested')) return
|
||||
window.electronAPI.export.resumeTask(taskId).then(result => {
|
||||
const doneAt = Date.now()
|
||||
if (!result.success) {
|
||||
updateTask(taskId, current => ({
|
||||
...current,
|
||||
status: 'error',
|
||||
finishedAt: doneAt,
|
||||
error: result.error || '继续任务失败',
|
||||
performance: finalizeTaskPerformance(current, doneAt)
|
||||
}))
|
||||
return
|
||||
}
|
||||
updateTask(taskId, current => ({
|
||||
...current,
|
||||
status: current.status === 'pause_requested' ? 'running' : 'queued',
|
||||
finishedAt: undefined,
|
||||
error: undefined,
|
||||
progress: {
|
||||
...current.progress,
|
||||
phaseLabel: current.status === 'pause_requested' ? '继续中' : '等待继续'
|
||||
}
|
||||
}))
|
||||
}).catch(error => {
|
||||
const doneAt = Date.now()
|
||||
updateTask(taskId, current => ({
|
||||
...current,
|
||||
status: 'error',
|
||||
finishedAt: doneAt,
|
||||
error: String(error),
|
||||
performance: finalizeTaskPerformance(current, doneAt)
|
||||
}))
|
||||
})
|
||||
}, [updateTask])
|
||||
const handleCancelExportTask = useCallback((taskId: string) => {
|
||||
const task = tasksRef.current.find(item => item.id === taskId)
|
||||
if (!task) return
|
||||
if (task.status === 'queued') {
|
||||
setTasks(prev => prev.filter(item => item.id !== taskId))
|
||||
return
|
||||
}
|
||||
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'paused' && task.status !== 'cancel_requested') {
|
||||
return
|
||||
}
|
||||
updateTask(taskId, current => ({
|
||||
...current,
|
||||
status: 'cancel_requested',
|
||||
progress: {
|
||||
...current.progress,
|
||||
phaseLabel: '取消请求已发送,正在安全停止'
|
||||
}
|
||||
}))
|
||||
window.electronAPI.export.cancelTask(taskId).then(result => {
|
||||
if (result.success && task.status === 'paused') {
|
||||
setTasks(prev => prev.filter(item => item.id !== taskId))
|
||||
return
|
||||
}
|
||||
if (!result.success) {
|
||||
const doneAt = Date.now()
|
||||
updateTask(taskId, current => ({
|
||||
...current,
|
||||
status: 'error',
|
||||
finishedAt: doneAt,
|
||||
error: result.error || '取消任务失败',
|
||||
performance: finalizeTaskPerformance(current, doneAt)
|
||||
}))
|
||||
}
|
||||
}).catch(error => {
|
||||
const doneAt = Date.now()
|
||||
updateTask(taskId, current => ({
|
||||
...current,
|
||||
status: 'error',
|
||||
finishedAt: doneAt,
|
||||
error: String(error),
|
||||
performance: finalizeTaskPerformance(current, doneAt)
|
||||
}))
|
||||
})
|
||||
}, [updateTask])
|
||||
|
||||
const toggleAutomationTaskEnabled = useCallback((taskId: string, enabled: boolean) => {
|
||||
const now = Date.now()
|
||||
@@ -8564,6 +8796,9 @@ function ExportPage() {
|
||||
nowTick={nowTick}
|
||||
onClose={closeTaskCenter}
|
||||
onTogglePerfTask={toggleTaskPerfDetail}
|
||||
onPauseExportTask={handlePauseExportTask}
|
||||
onResumeExportTask={handleResumeExportTask}
|
||||
onCancelExportTask={handleCancelExportTask}
|
||||
onPauseBackgroundTask={handlePauseBackgroundTask}
|
||||
onResumeBackgroundTask={handleResumeBackgroundTask}
|
||||
onCancelBackgroundTask={handleCancelBackgroundTask}
|
||||
@@ -8622,12 +8857,12 @@ function ExportPage() {
|
||||
<div className="automation-task-list">
|
||||
{sortedAutomationTasks.map((task) => {
|
||||
const linkedQueueTask = tasks.find((item) => (
|
||||
(item.status === 'running' || item.status === 'queued') &&
|
||||
isExportTaskActiveStatus(item.status) &&
|
||||
item.payload.automationTaskId === task.id
|
||||
))
|
||||
const queueState: 'queued' | 'running' | null = linkedQueueTask?.status === 'running'
|
||||
? 'running'
|
||||
: linkedQueueTask?.status === 'queued'
|
||||
: linkedQueueTask && isExportTaskActiveStatus(linkedQueueTask.status)
|
||||
? 'queued'
|
||||
: null
|
||||
return (
|
||||
|
||||
+78
-10
@@ -6,7 +6,7 @@ import { useThemeStore, themes } from '../stores/themeStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import { dialog } from '../services/ipc'
|
||||
import * as configService from '../services/config'
|
||||
import type { ContactInfo } from '../types/models'
|
||||
import type { ChatSession, ContactInfo } from '../types/models'
|
||||
import {
|
||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||
@@ -195,6 +195,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [launchAtStartup, setLaunchAtStartup] = useState(false)
|
||||
const [launchAtStartupSupported, setLaunchAtStartupSupported] = useState(isWindows || isMac)
|
||||
const [launchAtStartupReason, setLaunchAtStartupReason] = useState('')
|
||||
const [silentStartup, setSilentStartup] = useState(false)
|
||||
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
|
||||
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
|
||||
const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable')
|
||||
@@ -222,6 +223,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [isFetchingImageKey, setIsFetchingImageKey] = useState(false)
|
||||
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
|
||||
const [isUpdatingLaunchAtStartup, setIsUpdatingLaunchAtStartup] = useState(false)
|
||||
const [isUpdatingSilentStartup, setIsUpdatingSilentStartup] = useState(false)
|
||||
const [appVersion, setAppVersion] = useState('')
|
||||
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
|
||||
const [showDecryptKey, setShowDecryptKey] = useState(false)
|
||||
@@ -263,6 +265,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [messagePushFilterSearchKeyword, setMessagePushFilterSearchKeyword] = useState('')
|
||||
const [messagePushTypeFilter, setMessagePushTypeFilter] = useState<SessionFilterTypeValue>('all')
|
||||
const [messagePushContactOptions, setMessagePushContactOptions] = useState<ContactInfo[]>([])
|
||||
const [antiRevokeSessions, setAntiRevokeSessions] = useState<ChatSession[]>([])
|
||||
const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('')
|
||||
const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({})
|
||||
@@ -445,6 +448,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const savedMessagePushFilterList = await configService.getMessagePushFilterList()
|
||||
const contactsResult = await window.electronAPI.chat.getContacts({ lite: true })
|
||||
const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus()
|
||||
const savedSilentStartup = await configService.getSilentStartup()
|
||||
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
||||
const savedQuoteLayout = await configService.getQuoteLayout()
|
||||
const savedUpdateChannel = await configService.getUpdateChannel()
|
||||
@@ -502,6 +506,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
|
||||
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
|
||||
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
|
||||
setSilentStartup(savedSilentStartup)
|
||||
setWindowCloseBehavior(savedWindowCloseBehavior)
|
||||
setQuoteLayout(savedQuoteLayout)
|
||||
if (savedUpdateChannel) {
|
||||
@@ -615,6 +620,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSilentStartupChange = async (enabled: boolean) => {
|
||||
if (isUpdatingSilentStartup) return
|
||||
|
||||
try {
|
||||
setIsUpdatingSilentStartup(true)
|
||||
await configService.setSilentStartup(enabled)
|
||||
setSilentStartup(enabled)
|
||||
showMessage(enabled ? '已开启静默启动' : '已关闭静默启动', true)
|
||||
} catch (e: any) {
|
||||
showMessage(`设置静默启动失败: ${e?.message || String(e)}`, false)
|
||||
} finally {
|
||||
setIsUpdatingSilentStartup(false)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => {
|
||||
try {
|
||||
const result = await window.electronAPI.whisper?.getModelStatus()
|
||||
@@ -752,10 +772,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||
|
||||
const getCurrentAntiRevokeSessionIds = (): string[] =>
|
||||
normalizeSessionIds(chatSessions.map((session) => session.username))
|
||||
normalizeSessionIds(antiRevokeSessions.map((session) => session.username))
|
||||
|
||||
const ensureAntiRevokeSessionsLoaded = async (): Promise<string[]> => {
|
||||
const current = getCurrentAntiRevokeSessionIds()
|
||||
const ensureChatSessionsLoaded = async (): Promise<string[]> => {
|
||||
const current = normalizeSessionIds(chatSessions.map((session) => session.username))
|
||||
if (current.length > 0) return current
|
||||
const sessionsResult = await window.electronAPI.chat.getSessions()
|
||||
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||
@@ -765,6 +785,27 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
return normalizeSessionIds(sessionsResult.sessions.map((session) => session.username))
|
||||
}
|
||||
|
||||
const ensureAntiRevokeSessionsLoaded = async (): Promise<string[]> => {
|
||||
const current = getCurrentAntiRevokeSessionIds()
|
||||
if (current.length > 0) return current
|
||||
const sessionsResult = await window.electronAPI.chat.getAntiRevokeSessions()
|
||||
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||
throw new Error(sessionsResult.error || '加载会话失败')
|
||||
}
|
||||
const nextSessions = sessionsResult.sessions
|
||||
const nextIds = normalizeSessionIds(nextSessions.map((session) => session.username))
|
||||
setAntiRevokeSessions(nextSessions)
|
||||
setAntiRevokeSelectedIds((prev) => {
|
||||
const allowed = new Set(nextIds)
|
||||
return new Set(Array.from(prev).filter((sessionId) => allowed.has(sessionId)))
|
||||
})
|
||||
setAntiRevokeStatusMap((prev) => {
|
||||
const allowed = new Set(nextIds)
|
||||
return Object.fromEntries(Object.entries(prev).filter(([sessionId]) => allowed.has(sessionId)))
|
||||
})
|
||||
return nextIds
|
||||
}
|
||||
|
||||
const markAntiRevokeRowsLoading = (sessionIds: string[]) => {
|
||||
setAntiRevokeStatusMap((prev) => {
|
||||
const next = { ...prev }
|
||||
@@ -976,11 +1017,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
let canceled = false
|
||||
;(async () => {
|
||||
try {
|
||||
// 两个 Tab 都需要会话列表;antiRevoke 还需要额外检查防撤回状态
|
||||
const sessionIds = await ensureAntiRevokeSessionsLoaded()
|
||||
if (canceled) return
|
||||
if (activeTab === 'antiRevoke') {
|
||||
await handleRefreshAntiRevokeStatus(sessionIds)
|
||||
await ensureAntiRevokeSessionsLoaded()
|
||||
} else {
|
||||
await ensureChatSessionsLoaded()
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!canceled) {
|
||||
@@ -1684,6 +1724,35 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>静默启动</label>
|
||||
<span className="form-hint">
|
||||
开启后,无论手动启动还是开机自启动,都会先驻留到系统托盘,不主动显示主窗口。
|
||||
</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">
|
||||
{isUpdatingSilentStartup
|
||||
? '保存中...'
|
||||
: (silentStartup ? '已开启' : '已关闭')}
|
||||
</span>
|
||||
<label className="switch" htmlFor="silent-startup-toggle">
|
||||
<input
|
||||
id="silent-startup-toggle"
|
||||
className="switch-input"
|
||||
type="checkbox"
|
||||
checked={silentStartup}
|
||||
disabled={isUpdatingSilentStartup}
|
||||
onChange={(e) => {
|
||||
void handleSilentStartupChange(e.target.checked)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>关闭主窗口时</label>
|
||||
<span className="form-hint">设置点击关闭按钮后的默认行为;选择“每次询问”时会弹出关闭确认。</span>
|
||||
@@ -1982,7 +2051,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
}
|
||||
|
||||
const renderAntiRevokeTab = () => {
|
||||
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
|
||||
const sortedSessions = [...antiRevokeSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
|
||||
const keyword = antiRevokeSearchKeyword.trim().toLowerCase()
|
||||
const filteredSessions = sortedSessions.filter((session) => {
|
||||
if (!keyword) return true
|
||||
@@ -4761,4 +4830,3 @@ export default SettingsPage
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export const CONFIG_KEYS = {
|
||||
WINDOW_BOUNDS: 'windowBounds',
|
||||
CACHE_PATH: 'cachePath',
|
||||
LAUNCH_AT_STARTUP: 'launchAtStartup',
|
||||
SILENT_STARTUP: 'silentStartup',
|
||||
|
||||
EXPORT_PATH: 'exportPath',
|
||||
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
||||
@@ -321,6 +322,17 @@ export async function setLaunchAtStartup(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.LAUNCH_AT_STARTUP, enabled)
|
||||
}
|
||||
|
||||
// 获取静默启动偏好
|
||||
export async function getSilentStartup(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.SILENT_STARTUP)
|
||||
return value === true
|
||||
}
|
||||
|
||||
// 设置静默启动偏好
|
||||
export async function setSilentStartup(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.SILENT_STARTUP, enabled)
|
||||
}
|
||||
|
||||
// 获取 LLM 模型路径
|
||||
export async function getLlmModelPath(): Promise<string | null> {
|
||||
const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH)
|
||||
|
||||
Vendored
+112
-3
@@ -21,6 +21,88 @@ export interface SocialSaveWeiboCookieResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BackupProgress {
|
||||
phase: 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
|
||||
message: string
|
||||
current?: number
|
||||
total?: number
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface BackupOptions {
|
||||
includeImages?: boolean
|
||||
includeVideos?: boolean
|
||||
includeFiles?: boolean
|
||||
}
|
||||
|
||||
export interface BackupImageDatMeta {
|
||||
version?: number
|
||||
aesSize?: number
|
||||
aes_size?: number
|
||||
xorSize?: number
|
||||
xor_size?: number
|
||||
rawSize?: number
|
||||
raw_size?: number
|
||||
flag?: number
|
||||
}
|
||||
|
||||
export interface BackupManifest {
|
||||
version: 1
|
||||
type: 'weflow-db-snapshots'
|
||||
createdAt: string
|
||||
appVersion: string
|
||||
source: {
|
||||
wxid: string
|
||||
dbRoot: string
|
||||
}
|
||||
options?: BackupOptions
|
||||
databases: Array<{
|
||||
id: string
|
||||
kind: 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns' | 'hardlink'
|
||||
dbPath: string
|
||||
relativePath: string
|
||||
tables: Array<{
|
||||
name: string
|
||||
snapshotPath: string
|
||||
rows: number
|
||||
columns: number
|
||||
schemaSql?: string
|
||||
}>
|
||||
}>
|
||||
resources?: {
|
||||
images?: Array<{
|
||||
kind: 'image' | 'video' | 'file'
|
||||
id: string
|
||||
md5?: string
|
||||
sessionId?: string
|
||||
createTime?: number
|
||||
sourceFileName?: string
|
||||
archivePath: string
|
||||
targetRelativePath: string
|
||||
ext?: string
|
||||
size?: number
|
||||
datMeta?: BackupImageDatMeta
|
||||
}>
|
||||
videos?: Array<{
|
||||
kind: 'image' | 'video' | 'file'
|
||||
id: string
|
||||
md5?: string
|
||||
sourceFileName?: string
|
||||
archivePath: string
|
||||
targetRelativePath: string
|
||||
size?: number
|
||||
}>
|
||||
files?: Array<{
|
||||
kind: 'image' | 'video' | 'file'
|
||||
id: string
|
||||
sourceFileName?: string
|
||||
archivePath: string
|
||||
targetRelativePath: string
|
||||
size?: number
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
window: {
|
||||
minimize: () => void
|
||||
@@ -158,6 +240,27 @@ export interface ElectronAPI {
|
||||
close: () => Promise<boolean>
|
||||
|
||||
}
|
||||
backup: {
|
||||
create: (payload: { outputPath: string; options?: BackupOptions }) => Promise<{
|
||||
success: boolean
|
||||
filePath?: string
|
||||
manifest?: BackupManifest
|
||||
error?: string
|
||||
}>
|
||||
inspect: (payload: { archivePath: string }) => Promise<{
|
||||
success: boolean
|
||||
manifest?: BackupManifest
|
||||
error?: string
|
||||
}>
|
||||
restore: (payload: { archivePath: string }) => Promise<{
|
||||
success: boolean
|
||||
inserted?: number
|
||||
ignored?: number
|
||||
skipped?: number
|
||||
error?: string
|
||||
}>
|
||||
onProgress: (callback: (progress: BackupProgress) => void) => () => void
|
||||
}
|
||||
key: {
|
||||
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
|
||||
autoGetImageKey: (manualDir?: string, wxid?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }>
|
||||
@@ -168,6 +271,7 @@ export interface ElectronAPI {
|
||||
chat: {
|
||||
connect: () => Promise<{ success: boolean; error?: string }>
|
||||
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
|
||||
getAntiRevokeSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
|
||||
getSessionStatuses: (usernames: string[]) => Promise<{
|
||||
success: boolean
|
||||
map?: Record<string, { isFolded?: boolean; isMuted?: boolean }>
|
||||
@@ -988,16 +1092,21 @@ export interface ElectronAPI {
|
||||
estimatedSeconds: number
|
||||
sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }>
|
||||
}>
|
||||
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
|
||||
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => Promise<{
|
||||
success: boolean
|
||||
successCount?: number
|
||||
failCount?: number
|
||||
paused?: boolean
|
||||
stopped?: boolean
|
||||
pendingSessionIds?: string[]
|
||||
successSessionIds?: string[]
|
||||
failedSessionIds?: string[]
|
||||
sessionOutputPaths?: Record<string, string>
|
||||
error?: string
|
||||
}>
|
||||
pauseTask: (taskId: string) => Promise<{ success: boolean; error?: string }>
|
||||
resumeTask: (taskId: string) => Promise<{ success: boolean; error?: string }>
|
||||
cancelTask: (taskId: string) => Promise<{ success: boolean; error?: string }>
|
||||
exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
@@ -1070,7 +1179,8 @@ export interface ElectronAPI {
|
||||
exportVideos?: boolean
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }>
|
||||
taskId?: string
|
||||
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }>
|
||||
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
||||
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
||||
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
|
||||
@@ -1220,4 +1330,3 @@ declare global {
|
||||
}
|
||||
|
||||
export { }
|
||||
|
||||
Reference in new issue
Block a user