mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-26 07:26:46 +00:00
29
.github/scripts/release-utils.sh
vendored
29
.github/scripts/release-utils.sh
vendored
@@ -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"
|
||||
|
||||
8
.github/workflows/dev-daily-fixed.yml
vendored
8
.github/workflows/dev-daily-fixed.yml
vendored
@@ -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"
|
||||
|
||||
8
.github/workflows/preview-nightly-main.yml
vendored
8
.github/workflows/preview-nightly-main.yml
vendored
@@ -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"
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -76,4 +76,5 @@ wechat-research-site
|
||||
.codex
|
||||
weflow-web-offical
|
||||
/Wedecrypt
|
||||
/scripts/syncwcdb.py
|
||||
/scripts/syncwcdb.py
|
||||
/scripts/syncWedecrypt.py
|
||||
@@ -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({
|
||||
|
||||
192
electron/main.ts
192
electron/main.ts
@@ -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', () => {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
1084
electron/services/backupService.ts
Normal file
1084
electron/services/backupService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
210
electron/services/exportTaskControlService.ts
Normal file
210
electron/services/exportTaskControlService.ts
Normal file
@@ -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
|
||||
|
||||
15
package-lock.json
generated
15
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
298
src/pages/BackupPage.scss
Normal file
298
src/pages/BackupPage.scss
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
305
src/pages/BackupPage.tsx
Normal file
305
src/pages/BackupPage.tsx
Normal file
@@ -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
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
115
src/types/electron.d.ts
vendored
115
src/types/electron.d.ts
vendored
@@ -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