mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-25 07:26:47 +00:00
Merge branch 'hicccc77:dev' into dev
This commit is contained in:
6
.github/workflows/dev-daily-fixed.yml
vendored
6
.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 }}
|
||||
|
||||
6
.github/workflows/preview-nightly-main.yml
vendored
6
.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 }}
|
||||
|
||||
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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -77,3 +77,4 @@ wechat-research-site
|
||||
weflow-web-offical
|
||||
/Wedecrypt
|
||||
/scripts/syncwcdb.py
|
||||
/scripts/syncWedecrypt.py
|
||||
@@ -33,6 +33,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
|
||||
@@ -2178,6 +2179,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)
|
||||
})
|
||||
|
||||
|
||||
|
||||
// 聊天相关
|
||||
@@ -3996,4 +4009,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'),
|
||||
|
||||
986
electron/services/backupService.ts
Normal file
986
electron/services/backupService.ts
Normal file
@@ -0,0 +1,986 @@
|
||||
import { BrowserWindow, app } from 'electron'
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'fs'
|
||||
import { copyFile, link, readFile as readFileAsync, mkdtemp, writeFile } from 'fs/promises'
|
||||
import { basename, dirname, join, relative, resolve, sep } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import * as tar from 'tar'
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { expandHomePath } from '../utils/pathUtils'
|
||||
import { decryptDatViaNative, encryptDatViaNative } from './nativeImageDecrypt'
|
||||
|
||||
type BackupDbKind = 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns'
|
||||
type BackupPhase = 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
|
||||
type BackupResourceKind = 'image' | 'video' | 'file'
|
||||
|
||||
export interface BackupOptions {
|
||||
includeImages?: boolean
|
||||
includeVideos?: boolean
|
||||
includeFiles?: boolean
|
||||
}
|
||||
|
||||
interface BackupDbEntry {
|
||||
id: string
|
||||
kind: BackupDbKind
|
||||
dbPath: string
|
||||
relativePath: string
|
||||
tables: BackupTableEntry[]
|
||||
}
|
||||
|
||||
interface BackupTableEntry {
|
||||
name: string
|
||||
snapshotPath: string
|
||||
rows: number
|
||||
columns: number
|
||||
schemaSql?: string
|
||||
}
|
||||
|
||||
interface BackupResourceEntry {
|
||||
kind: BackupResourceKind
|
||||
id: string
|
||||
md5?: string
|
||||
sessionId?: string
|
||||
createTime?: number
|
||||
sourceFileName?: string
|
||||
archivePath: string
|
||||
targetRelativePath: string
|
||||
ext?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface BackupManifest {
|
||||
version: 1
|
||||
type: 'weflow-db-snapshots'
|
||||
createdAt: string
|
||||
appVersion: string
|
||||
source: {
|
||||
wxid: string
|
||||
dbRoot: string
|
||||
}
|
||||
databases: BackupDbEntry[]
|
||||
options?: BackupOptions
|
||||
resources?: {
|
||||
images?: BackupResourceEntry[]
|
||||
videos?: BackupResourceEntry[]
|
||||
files?: BackupResourceEntry[]
|
||||
}
|
||||
}
|
||||
|
||||
interface BackupProgress {
|
||||
phase: BackupPhase
|
||||
message: string
|
||||
current?: number
|
||||
total?: number
|
||||
detail?: string
|
||||
}
|
||||
|
||||
function emitBackupProgress(progress: BackupProgress): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('backup:progress', progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeName(value: string): string {
|
||||
return encodeURIComponent(value || 'unnamed').replace(/%/g, '_')
|
||||
}
|
||||
|
||||
function toArchivePath(path: string): string {
|
||||
return path.split(sep).join('/')
|
||||
}
|
||||
|
||||
async function withTimeout<T>(task: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
try {
|
||||
return await Promise.race([
|
||||
task,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms = 0): Promise<void> {
|
||||
return new Promise(resolveDelay => setTimeout(resolveDelay, ms))
|
||||
}
|
||||
|
||||
function createThrottledProgressEmitter(minIntervalMs = 120): (progress: BackupProgress, force?: boolean) => void {
|
||||
let lastEmitAt = 0
|
||||
return (progress: BackupProgress, force = false) => {
|
||||
const now = Date.now()
|
||||
if (!force && now - lastEmitAt < minIntervalMs) return
|
||||
lastEmitAt = now
|
||||
emitBackupProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<void>
|
||||
): Promise<void> {
|
||||
let nextIndex = 0
|
||||
const workerCount = Math.max(1, Math.min(concurrency, items.length))
|
||||
await Promise.all(Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const index = nextIndex
|
||||
nextIndex += 1
|
||||
if (index >= items.length) return
|
||||
await worker(items[index], index)
|
||||
if (index % 50 === 0) await delay()
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function hasResourceOptions(options: BackupOptions): boolean {
|
||||
return options.includeImages === true || options.includeVideos === true || options.includeFiles === true
|
||||
}
|
||||
|
||||
export class BackupService {
|
||||
private configService = new ConfigService()
|
||||
|
||||
private buildWxidCandidates(wxid: string): string[] {
|
||||
const wxidCandidates = Array.from(new Set([
|
||||
String(wxid || '').trim(),
|
||||
this.cleanAccountDirName(wxid)
|
||||
].filter(Boolean)))
|
||||
return wxidCandidates
|
||||
}
|
||||
|
||||
private isCurrentAccountDir(accountDir: string, wxidCandidates: string[]): boolean {
|
||||
const accountName = basename(accountDir).toLowerCase()
|
||||
return wxidCandidates
|
||||
.map(item => item.toLowerCase())
|
||||
.some(wxid => accountName === wxid || accountName.startsWith(`${wxid}_`))
|
||||
}
|
||||
|
||||
private normalizeExistingPath(inputPath: string): string {
|
||||
const expanded = expandHomePath(String(inputPath || '').trim()).replace(/[\\/]+$/, '')
|
||||
if (!expanded) return expanded
|
||||
try {
|
||||
if (existsSync(expanded) && statSync(expanded).isFile()) {
|
||||
return dirname(expanded)
|
||||
}
|
||||
} catch {}
|
||||
return expanded
|
||||
}
|
||||
|
||||
private resolveAncestorDbStorage(normalized: string, wxidCandidates: string[]): string | null {
|
||||
let current = normalized
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
if (!current) break
|
||||
if (basename(current).toLowerCase() === 'db_storage') {
|
||||
const accountDir = dirname(current)
|
||||
if (this.isCurrentAccountDir(accountDir, wxidCandidates) && existsSync(current)) {
|
||||
return current
|
||||
}
|
||||
}
|
||||
const parent = dirname(current)
|
||||
if (!parent || parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveCurrentAccountDbStorageFromRoot(rootPath: string, wxidCandidates: string[]): string | null {
|
||||
if (!rootPath || !existsSync(rootPath)) return null
|
||||
|
||||
for (const candidateWxid of wxidCandidates) {
|
||||
const viaWxid = join(rootPath, candidateWxid, 'db_storage')
|
||||
if (existsSync(viaWxid)) return viaWxid
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(rootPath)
|
||||
const loweredWxids = wxidCandidates.map(item => item.toLowerCase())
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(rootPath, entry)
|
||||
try {
|
||||
if (!statSync(entryPath).isDirectory()) continue
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const lowerEntry = entry.toLowerCase()
|
||||
if (!loweredWxids.some(id => lowerEntry === id || lowerEntry.startsWith(`${id}_`))) continue
|
||||
const candidate = join(entryPath, 'db_storage')
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveDbStoragePath(dbPath: string, wxid: string): string | null {
|
||||
const normalized = this.normalizeExistingPath(dbPath)
|
||||
if (!normalized) return null
|
||||
|
||||
const wxidCandidates = this.buildWxidCandidates(wxid)
|
||||
const ancestor = this.resolveAncestorDbStorage(normalized, wxidCandidates)
|
||||
if (ancestor) return ancestor
|
||||
|
||||
const direct = join(normalized, 'db_storage')
|
||||
if (existsSync(direct) && this.isCurrentAccountDir(normalized, wxidCandidates)) return direct
|
||||
|
||||
const roots = Array.from(new Set([
|
||||
normalized,
|
||||
join(normalized, 'WeChat Files'),
|
||||
join(normalized, 'xwechat_files')
|
||||
]))
|
||||
for (const root of roots) {
|
||||
const dbStorage = this.resolveCurrentAccountDbStorageFromRoot(root, wxidCandidates)
|
||||
if (dbStorage) return dbStorage
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
||||
const dbStorage = this.resolveDbStoragePath(dbPath, wxid)
|
||||
return dbStorage ? dirname(dbStorage) : null
|
||||
}
|
||||
|
||||
private cleanAccountDirName(wxid: string): string {
|
||||
const trimmed = String(wxid || '').trim()
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
return match?.[1] || trimmed
|
||||
}
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
private parseImageXorKey(value: unknown): number {
|
||||
if (typeof value === 'number') return value
|
||||
const text = String(value ?? '').trim()
|
||||
if (!text) return Number.NaN
|
||||
return text.toLowerCase().startsWith('0x') ? parseInt(text, 16) : parseInt(text, 10)
|
||||
}
|
||||
|
||||
private getImageKeysForWxid(wxid: string): { xorKey: number; aesKey?: string } | null {
|
||||
const wxidConfigs = this.configService.get('wxidConfigs') || {}
|
||||
const candidates = this.buildWxidCandidates(wxid)
|
||||
const matchedKey = Object.keys(wxidConfigs).find((key) => {
|
||||
const cleanKey = this.cleanAccountDirName(key).toLowerCase()
|
||||
return candidates.some(candidate => cleanKey === candidate.toLowerCase())
|
||||
})
|
||||
const cfg = matchedKey ? wxidConfigs[matchedKey] : undefined
|
||||
const xorKey = this.parseImageXorKey(cfg?.imageXorKey ?? this.configService.get('imageXorKey'))
|
||||
if (!Number.isFinite(xorKey)) return null
|
||||
const aesKey = String(cfg?.imageAesKey ?? this.configService.get('imageAesKey') ?? '').trim()
|
||||
return { xorKey, aesKey: aesKey || undefined }
|
||||
}
|
||||
|
||||
private async listFilesForArchive(root: string, rel = '', state = { visited: 0 }): Promise<string[]> {
|
||||
const dir = join(root, rel)
|
||||
const files: string[] = []
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const entryRel = rel ? join(rel, entry) : entry
|
||||
const entryPath = join(root, entryRel)
|
||||
try {
|
||||
const stat = statSync(entryPath)
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...await this.listFilesForArchive(root, entryRel, state))
|
||||
} else if (stat.isFile()) {
|
||||
files.push(toArchivePath(entryRel))
|
||||
}
|
||||
state.visited += 1
|
||||
if (state.visited % 200 === 0) await delay()
|
||||
} catch {}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
private resolveExtractedPath(extractDir: string, archivePath: string): string | null {
|
||||
const normalized = String(archivePath || '').replace(/\\/g, '/')
|
||||
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
||||
const root = resolve(extractDir)
|
||||
const target = resolve(join(extractDir, normalized))
|
||||
if (target !== root && !target.startsWith(`${root}${sep}`)) return null
|
||||
return target
|
||||
}
|
||||
|
||||
private resolveTargetResourcePath(accountDir: string, relativePath: string): string | null {
|
||||
const normalized = String(relativePath || '').replace(/\\/g, '/')
|
||||
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
||||
const root = resolve(accountDir)
|
||||
const target = resolve(join(accountDir, normalized))
|
||||
if (target !== root && !target.startsWith(`${root}${sep}`)) return null
|
||||
return target
|
||||
}
|
||||
|
||||
private isSafeAccountRelativePath(accountDir: string, filePath: string): string | null {
|
||||
const rel = toArchivePath(relative(accountDir, filePath))
|
||||
if (!rel || rel.startsWith('..') || rel.startsWith('/')) return null
|
||||
return rel
|
||||
}
|
||||
|
||||
private async listFilesUnderDir(root: string, state = { visited: 0 }): Promise<string[]> {
|
||||
const files: string[] = []
|
||||
if (!existsSync(root)) return files
|
||||
try {
|
||||
for (const entry of readdirSync(root)) {
|
||||
const fullPath = join(root, entry)
|
||||
let stat
|
||||
try {
|
||||
stat = statSync(fullPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...await this.listFilesUnderDir(fullPath, state))
|
||||
} else if (stat.isFile()) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
state.visited += 1
|
||||
if (state.visited % 300 === 0) await delay()
|
||||
}
|
||||
} catch {}
|
||||
return files
|
||||
}
|
||||
|
||||
private async stagePlainResource(sourcePath: string, outputPath: string): Promise<void> {
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
try {
|
||||
await link(sourcePath, outputPath)
|
||||
} catch {
|
||||
await copyFile(sourcePath, outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
private async listChatImageDatFiles(accountDir: string): Promise<string[]> {
|
||||
const attachRoot = join(accountDir, 'msg', 'attach')
|
||||
const result: string[] = []
|
||||
if (!existsSync(attachRoot)) return result
|
||||
|
||||
const scanImgDir = async (imgDir: string): Promise<void> => {
|
||||
let entries: string[] = []
|
||||
try {
|
||||
entries = readdirSync(imgDir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(imgDir, entry)
|
||||
let stat
|
||||
try {
|
||||
stat = statSync(fullPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (stat.isFile() && entry.toLowerCase().endsWith('.dat')) {
|
||||
result.push(fullPath)
|
||||
} else if (stat.isDirectory()) {
|
||||
let nestedEntries: string[] = []
|
||||
try {
|
||||
nestedEntries = readdirSync(fullPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const nestedEntry of nestedEntries) {
|
||||
const nestedPath = join(fullPath, nestedEntry)
|
||||
try {
|
||||
if (statSync(nestedPath).isFile() && nestedEntry.toLowerCase().endsWith('.dat')) {
|
||||
result.push(nestedPath)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (result.length > 0 && result.length % 500 === 0) await delay()
|
||||
}
|
||||
}
|
||||
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
let entries: Array<{ name: string; isDirectory: () => boolean }> = []
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const child = join(dir, entry.name)
|
||||
if (entry.name.toLowerCase() === 'img') {
|
||||
await scanImgDir(child)
|
||||
} else {
|
||||
await walk(child)
|
||||
}
|
||||
if (result.length > 0 && result.length % 500 === 0) await delay()
|
||||
}
|
||||
}
|
||||
|
||||
await walk(attachRoot)
|
||||
return Array.from(new Set(result))
|
||||
}
|
||||
|
||||
private async ensureConnected(wxidOverride?: string): Promise<{ success: boolean; wxid?: string; dbPath?: string; dbStorage?: string; error?: string }> {
|
||||
const configuredWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const wxid = String(wxidOverride || configuredWxid || '').trim()
|
||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||
const decryptKey = String(this.configService.get('decryptKey') || '').trim()
|
||||
if (!wxid || !dbPath) return { success: false, error: '请先配置数据库路径和微信账号' }
|
||||
if (!decryptKey) return { success: false, error: '请先配置数据库解密密钥' }
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) return { success: false, error: `未在配置的 dbPath 下找到账号目录:${wxid}` }
|
||||
const dbStorage = join(accountDir, 'db_storage')
|
||||
if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' }
|
||||
|
||||
const accountDirName = basename(accountDir)
|
||||
const opened = await withTimeout(
|
||||
wcdbService.open(dbPath, decryptKey, accountDirName),
|
||||
15000,
|
||||
'连接目标账号数据库超时,请检查数据库路径、密钥是否正确'
|
||||
)
|
||||
if (!opened) {
|
||||
const detail = await wcdbService.getLastInitError().catch(() => null)
|
||||
return { success: false, error: detail || `目标账号 ${accountDirName} 数据库连接失败` }
|
||||
}
|
||||
|
||||
return { success: true, wxid: accountDirName, dbPath, dbStorage }
|
||||
}
|
||||
|
||||
private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string {
|
||||
if (kind === 'session' || kind === 'contact' || kind === 'emoticon' || kind === 'sns') return kind
|
||||
return `${kind}-${index}-${safeName(basename(dbPath)).slice(0, 80)}`
|
||||
}
|
||||
|
||||
private toDbRelativePath(dbStorage: string, dbPath: string): string {
|
||||
const rel = toArchivePath(relative(dbStorage, dbPath))
|
||||
if (!rel || rel.startsWith('..') || rel.startsWith('/')) return basename(dbPath)
|
||||
return rel
|
||||
}
|
||||
|
||||
private resolveTargetDbPath(dbStorage: string, relativePath: string): string | null {
|
||||
const normalized = String(relativePath || '').replace(/\\/g, '/')
|
||||
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
||||
const root = resolve(dbStorage)
|
||||
const target = resolve(join(dbStorage, normalized))
|
||||
if (target !== root && !target.startsWith(`${root}${sep}`)) return null
|
||||
return target
|
||||
}
|
||||
|
||||
private defaultRelativeDbPath(kind: BackupDbKind): string | null {
|
||||
if (kind === 'session') return 'session/session.db'
|
||||
if (kind === 'contact') return 'contact/contact.db'
|
||||
if (kind === 'emoticon') return 'emoticon/emoticon.db'
|
||||
if (kind === 'sns') return 'sns/sns.db'
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveRestoreTargetDbPath(dbStorage: string, db: BackupDbEntry): string | null {
|
||||
const normalized = String(db.relativePath || '').replace(/\\/g, '/')
|
||||
const legacyFixedPath = this.defaultRelativeDbPath(db.kind)
|
||||
if (legacyFixedPath && (!normalized.includes('/') || !normalized.toLowerCase().endsWith('.db'))) {
|
||||
return this.resolveTargetDbPath(dbStorage, legacyFixedPath)
|
||||
}
|
||||
return this.resolveTargetDbPath(dbStorage, db.relativePath)
|
||||
}
|
||||
|
||||
private findFirstExisting(paths: string[]): string {
|
||||
for (const path of paths) {
|
||||
try {
|
||||
if (existsSync(path) && statSync(path).isFile()) return path
|
||||
} catch {}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private resolveKnownDbPath(kind: BackupDbKind, dbStorage: string): string {
|
||||
if (kind === 'session') {
|
||||
return this.findFirstExisting([
|
||||
join(dbStorage, 'session', 'session.db'),
|
||||
join(dbStorage, 'Session', 'session.db'),
|
||||
join(dbStorage, 'session.db')
|
||||
])
|
||||
}
|
||||
if (kind === 'contact') {
|
||||
return this.findFirstExisting([
|
||||
join(dbStorage, 'Contact', 'contact.db'),
|
||||
join(dbStorage, 'Contact', 'Contact.db'),
|
||||
join(dbStorage, 'contact', 'contact.db'),
|
||||
join(dbStorage, 'session', 'contact.db')
|
||||
])
|
||||
}
|
||||
if (kind === 'emoticon') {
|
||||
return this.findFirstExisting([
|
||||
join(dbStorage, 'emoticon', 'emoticon.db'),
|
||||
join(dbStorage, 'emotion', 'emoticon.db')
|
||||
])
|
||||
}
|
||||
if (kind === 'sns') {
|
||||
return this.findFirstExisting([
|
||||
join(dbStorage, 'sns', 'sns.db'),
|
||||
join(dirname(dbStorage), 'sns', 'sns.db')
|
||||
])
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private async collectDatabases(dbStorage: string): Promise<Array<Omit<BackupDbEntry, 'tables'>>> {
|
||||
const result: Array<Omit<BackupDbEntry, 'tables'>> = []
|
||||
for (const kind of ['session', 'contact', 'emoticon', 'sns'] as const) {
|
||||
const dbPath = this.resolveKnownDbPath(kind, dbStorage)
|
||||
result.push({
|
||||
id: kind,
|
||||
kind,
|
||||
dbPath,
|
||||
relativePath: dbPath ? this.toDbRelativePath(dbStorage, dbPath) : kind
|
||||
})
|
||||
}
|
||||
|
||||
const messageDbs = await wcdbService.listMessageDbs()
|
||||
if (messageDbs.success && Array.isArray(messageDbs.data)) {
|
||||
messageDbs.data.forEach((dbPath, index) => {
|
||||
result.push({
|
||||
id: this.buildDbId('message', index, dbPath),
|
||||
kind: 'message',
|
||||
dbPath,
|
||||
relativePath: this.toDbRelativePath(dbStorage, dbPath)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const mediaDbs = await wcdbService.listMediaDbs()
|
||||
if (mediaDbs.success && Array.isArray(mediaDbs.data)) {
|
||||
mediaDbs.data.forEach((dbPath, index) => {
|
||||
result.push({
|
||||
id: this.buildDbId('media', index, dbPath),
|
||||
kind: 'media',
|
||||
dbPath,
|
||||
relativePath: this.toDbRelativePath(dbStorage, dbPath)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async collectImageResources(
|
||||
connected: { wxid: string; dbStorage: string },
|
||||
stagingDir: string,
|
||||
manifest: BackupManifest
|
||||
): Promise<void> {
|
||||
const accountDir = dirname(connected.dbStorage)
|
||||
const keys = this.getImageKeysForWxid(connected.wxid)
|
||||
const imagesDir = join(stagingDir, 'resources', 'images')
|
||||
const imagePaths = await this.listChatImageDatFiles(accountDir)
|
||||
if (imagePaths.length === 0) return
|
||||
if (!keys) throw new Error('存在图片资源,但未配置图片解密密钥')
|
||||
|
||||
mkdirSync(imagesDir, { recursive: true })
|
||||
const resources: BackupResourceEntry[] = []
|
||||
const emitImageProgress = createThrottledProgressEmitter(160)
|
||||
for (let index = 0; index < imagePaths.length; index += 1) {
|
||||
const sourcePath = imagePaths[index]
|
||||
const relativeTarget = this.isSafeAccountRelativePath(accountDir, sourcePath)
|
||||
if (!relativeTarget) continue
|
||||
emitImageProgress({
|
||||
phase: 'exporting',
|
||||
message: '正在解密图片资源',
|
||||
current: index + 1,
|
||||
total: imagePaths.length,
|
||||
detail: relativeTarget
|
||||
})
|
||||
const decrypted = decryptDatViaNative(sourcePath, keys.xorKey, keys.aesKey)
|
||||
if (!decrypted) continue
|
||||
const archivePath = toArchivePath(join('resources', 'images', `${relativeTarget}${decrypted.ext || '.bin'}`))
|
||||
const outputPath = join(stagingDir, archivePath)
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
await writeFile(outputPath, decrypted.data)
|
||||
const stem = basename(sourcePath).replace(/\.dat$/i, '').toLowerCase()
|
||||
resources.push({
|
||||
kind: 'image',
|
||||
id: relativeTarget,
|
||||
md5: /^[a-f0-9]{32}$/i.test(stem) ? stem : undefined,
|
||||
sourceFileName: basename(sourcePath),
|
||||
archivePath,
|
||||
targetRelativePath: relativeTarget,
|
||||
ext: decrypted.ext || undefined,
|
||||
size: decrypted.data.length
|
||||
})
|
||||
if (index % 20 === 0) await delay()
|
||||
}
|
||||
|
||||
if (resources.length > 0) {
|
||||
manifest.resources = { ...(manifest.resources || {}), images: resources }
|
||||
}
|
||||
}
|
||||
|
||||
private async collectPlainResources(
|
||||
connected: { dbStorage: string },
|
||||
stagingDir: string,
|
||||
manifest: BackupManifest,
|
||||
kind: 'video' | 'file'
|
||||
): Promise<void> {
|
||||
const accountDir = dirname(connected.dbStorage)
|
||||
const roots = kind === 'video'
|
||||
? [
|
||||
join(accountDir, 'msg', 'video'),
|
||||
join(accountDir, 'FileStorage', 'Video')
|
||||
]
|
||||
: [
|
||||
join(accountDir, 'FileStorage', 'File'),
|
||||
join(accountDir, 'msg', 'file')
|
||||
]
|
||||
const listed = await Promise.all(roots.map(root => this.listFilesUnderDir(root)))
|
||||
const uniqueFiles = Array.from(new Set(listed.flat()))
|
||||
if (uniqueFiles.length === 0) return
|
||||
|
||||
const resources: BackupResourceEntry[] = []
|
||||
const bucket = kind === 'video' ? 'videos' : 'files'
|
||||
const emitResourceProgress = createThrottledProgressEmitter(180)
|
||||
await runWithConcurrency(uniqueFiles, 4, async (sourcePath, index) => {
|
||||
emitResourceProgress({
|
||||
phase: 'exporting',
|
||||
message: kind === 'video' ? '正在归档视频资源' : '正在归档文件资源',
|
||||
current: index + 1,
|
||||
total: uniqueFiles.length,
|
||||
detail: basename(sourcePath)
|
||||
})
|
||||
const relativeTarget = this.isSafeAccountRelativePath(accountDir, sourcePath)
|
||||
if (!relativeTarget) return
|
||||
const archivePath = toArchivePath(join('resources', bucket, relativeTarget))
|
||||
const outputPath = join(stagingDir, archivePath)
|
||||
await this.stagePlainResource(sourcePath, outputPath)
|
||||
let size = 0
|
||||
try { size = statSync(sourcePath).size } catch {}
|
||||
const entry: BackupResourceEntry = {
|
||||
kind,
|
||||
id: relativeTarget,
|
||||
sourceFileName: basename(sourcePath),
|
||||
archivePath,
|
||||
targetRelativePath: relativeTarget,
|
||||
size
|
||||
}
|
||||
resources.push(entry)
|
||||
})
|
||||
|
||||
if (resources.length > 0) {
|
||||
manifest.resources = {
|
||||
...(manifest.resources || {}),
|
||||
[bucket]: resources
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createBackup(outputPath: string, options: BackupOptions = {}): Promise<{ success: boolean; filePath?: string; manifest?: BackupManifest; error?: string }> {
|
||||
let stagingDir = ''
|
||||
try {
|
||||
emitBackupProgress({ phase: 'preparing', message: '正在连接数据库' })
|
||||
const connected = await this.ensureConnected()
|
||||
if (!connected.success || !connected.wxid || !connected.dbPath || !connected.dbStorage) {
|
||||
return { success: false, error: connected.error || '数据库未连接' }
|
||||
}
|
||||
|
||||
stagingDir = await mkdtemp(join(tmpdir(), 'weflow-backup-'))
|
||||
const snapshotsDir = join(stagingDir, 'snapshots')
|
||||
mkdirSync(snapshotsDir, { recursive: true })
|
||||
|
||||
const dbs = await this.collectDatabases(connected.dbStorage)
|
||||
const manifest: BackupManifest = {
|
||||
version: 1,
|
||||
type: 'weflow-db-snapshots',
|
||||
createdAt: new Date().toISOString(),
|
||||
appVersion: app.getVersion(),
|
||||
source: {
|
||||
wxid: connected.wxid,
|
||||
dbRoot: connected.dbPath
|
||||
},
|
||||
databases: [],
|
||||
options: {
|
||||
includeImages: options.includeImages === true,
|
||||
includeVideos: options.includeVideos === true,
|
||||
includeFiles: options.includeFiles === true
|
||||
}
|
||||
}
|
||||
|
||||
const tableJobs: Array<{ db: Omit<BackupDbEntry, 'tables'>; table: string; schemaSql: string; snapshotPath: string; outputPath: string }> = []
|
||||
for (let index = 0; index < dbs.length; index += 1) {
|
||||
const db = dbs[index]
|
||||
emitBackupProgress({
|
||||
phase: 'scanning',
|
||||
message: '正在扫描数据库和表',
|
||||
current: index + 1,
|
||||
total: dbs.length,
|
||||
detail: `${db.kind}:${db.relativePath || db.dbPath || db.id}`
|
||||
})
|
||||
const tablesResult = await wcdbService.listTables(db.kind, db.dbPath)
|
||||
if (!tablesResult.success || !Array.isArray(tablesResult.tables) || tablesResult.tables.length === 0) continue
|
||||
const dbDir = join(snapshotsDir, db.id)
|
||||
mkdirSync(dbDir, { recursive: true })
|
||||
const entry: BackupDbEntry = { ...db, tables: [] }
|
||||
manifest.databases.push(entry)
|
||||
for (const table of tablesResult.tables) {
|
||||
const schemaResult = await wcdbService.getTableSchema(db.kind, db.dbPath, table)
|
||||
if (!schemaResult.success || !schemaResult.schema) continue
|
||||
const snapshotPath = toArchivePath(join('snapshots', db.id, `${safeName(table)}.wfsnap`))
|
||||
tableJobs.push({
|
||||
db,
|
||||
table,
|
||||
schemaSql: schemaResult.schema,
|
||||
snapshotPath,
|
||||
outputPath: join(stagingDir, snapshotPath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let current = 0
|
||||
for (const job of tableJobs) {
|
||||
current++
|
||||
emitBackupProgress({
|
||||
phase: 'exporting',
|
||||
message: '正在导出数据库快照',
|
||||
current,
|
||||
total: tableJobs.length,
|
||||
detail: `${job.db.kind}:${job.table}`
|
||||
})
|
||||
const exported = await wcdbService.exportTableSnapshot(job.db.kind, job.db.dbPath, job.table, job.outputPath)
|
||||
if (!exported.success) {
|
||||
throw new Error(`${job.db.kind}:${job.table} 导出失败:${exported.error || 'unknown'}`)
|
||||
}
|
||||
const dbEntry = manifest.databases.find(item => item.id === job.db.id)
|
||||
dbEntry?.tables.push({
|
||||
name: job.table,
|
||||
snapshotPath: job.snapshotPath,
|
||||
rows: exported.rows || 0,
|
||||
columns: exported.columns || 0,
|
||||
schemaSql: job.schemaSql
|
||||
})
|
||||
}
|
||||
|
||||
if (options.includeImages === true) {
|
||||
await this.collectImageResources(
|
||||
{ wxid: connected.wxid, dbStorage: connected.dbStorage },
|
||||
stagingDir,
|
||||
manifest
|
||||
)
|
||||
}
|
||||
if (options.includeVideos === true) {
|
||||
await this.collectPlainResources({ dbStorage: connected.dbStorage }, stagingDir, manifest, 'video')
|
||||
}
|
||||
if (options.includeFiles === true) {
|
||||
await this.collectPlainResources({ dbStorage: connected.dbStorage }, stagingDir, manifest, 'file')
|
||||
}
|
||||
|
||||
await writeFile(join(stagingDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8')
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
const archiveFiles = await this.listFilesForArchive(stagingDir)
|
||||
const shouldCompress = !hasResourceOptions(options)
|
||||
let packed = 0
|
||||
const emitPackingProgress = createThrottledProgressEmitter(150)
|
||||
emitBackupProgress({ phase: 'packing', message: '正在生成备份包', current: 0, total: archiveFiles.length })
|
||||
await tar.c({
|
||||
gzip: shouldCompress ? { level: 1 } : false,
|
||||
cwd: stagingDir,
|
||||
file: outputPath,
|
||||
portable: true,
|
||||
noMtime: true,
|
||||
sync: false,
|
||||
onWriteEntry: (entry: any) => {
|
||||
packed += 1
|
||||
emitPackingProgress({
|
||||
phase: 'packing',
|
||||
message: '正在写入备份包',
|
||||
current: Math.min(packed, archiveFiles.length),
|
||||
total: archiveFiles.length,
|
||||
detail: String(entry?.path || entry || '')
|
||||
})
|
||||
}
|
||||
} as any, archiveFiles)
|
||||
emitBackupProgress({
|
||||
phase: 'packing',
|
||||
message: '正在写入备份包',
|
||||
current: archiveFiles.length,
|
||||
total: archiveFiles.length
|
||||
})
|
||||
emitBackupProgress({ phase: 'done', message: '备份完成', current: tableJobs.length, total: tableJobs.length })
|
||||
return { success: true, filePath: outputPath, manifest }
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : String(e)
|
||||
emitBackupProgress({ phase: 'failed', message: error })
|
||||
return { success: false, error }
|
||||
} finally {
|
||||
if (stagingDir) {
|
||||
try { rmSync(stagingDir, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async inspectBackup(archivePath: string): Promise<{ success: boolean; manifest?: BackupManifest; error?: string }> {
|
||||
let extractDir = ''
|
||||
try {
|
||||
emitBackupProgress({ phase: 'inspecting', message: '正在读取备份包' })
|
||||
extractDir = await mkdtemp(join(tmpdir(), 'weflow-backup-inspect-'))
|
||||
await tar.x({
|
||||
file: archivePath,
|
||||
cwd: extractDir,
|
||||
filter: (entryPath: string) => entryPath.replace(/\\/g, '/') === 'manifest.json'
|
||||
} as any)
|
||||
const manifestPath = join(extractDir, 'manifest.json')
|
||||
if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' }
|
||||
const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest
|
||||
if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) {
|
||||
return { success: false, error: '不支持的备份包格式' }
|
||||
}
|
||||
return { success: true, manifest }
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : String(e) }
|
||||
} finally {
|
||||
if (extractDir) {
|
||||
try { rmSync(extractDir, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async restoreBackup(archivePath: string): Promise<{ success: boolean; inserted?: number; ignored?: number; skipped?: number; error?: string }> {
|
||||
let extractDir = ''
|
||||
try {
|
||||
emitBackupProgress({ phase: 'inspecting', message: '正在解包备份' })
|
||||
extractDir = await mkdtemp(join(tmpdir(), 'weflow-backup-restore-'))
|
||||
await tar.x({ file: archivePath, cwd: extractDir })
|
||||
const manifestPath = join(extractDir, 'manifest.json')
|
||||
if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' }
|
||||
const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest
|
||||
if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) {
|
||||
return { success: false, error: '不支持的备份包格式' }
|
||||
}
|
||||
const targetWxid = String(manifest.source?.wxid || '').trim()
|
||||
if (!targetWxid) return { success: false, error: '备份包缺少来源账号 wxid,无法定位目标账号目录' }
|
||||
|
||||
emitBackupProgress({ phase: 'preparing', message: '正在连接目标数据库', detail: targetWxid })
|
||||
const connected = await this.ensureConnected(targetWxid)
|
||||
if (!connected.success || !connected.dbStorage) return { success: false, error: connected.error || '数据库未连接' }
|
||||
|
||||
const tableJobs = manifest.databases.flatMap(db => db.tables.map(table => ({ db, table })))
|
||||
const imageJobs = manifest.resources?.images || []
|
||||
const plainResourceJobs = [
|
||||
...(manifest.resources?.videos || []),
|
||||
...(manifest.resources?.files || [])
|
||||
]
|
||||
const totalRestoreJobs = tableJobs.length + imageJobs.length + plainResourceJobs.length
|
||||
let inserted = 0
|
||||
let ignored = 0
|
||||
let skipped = 0
|
||||
let current = 0
|
||||
for (const job of tableJobs) {
|
||||
current++
|
||||
const targetDbPath = this.resolveRestoreTargetDbPath(connected.dbStorage, job.db)
|
||||
if (targetDbPath === null) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if (!job.table.schemaSql) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
emitBackupProgress({
|
||||
phase: 'restoring',
|
||||
message: '正在通过 WCDB 写入数据库',
|
||||
current,
|
||||
total: totalRestoreJobs,
|
||||
detail: `${job.db.kind}:${job.table.name}`
|
||||
})
|
||||
const inputPath = this.resolveExtractedPath(extractDir, job.table.snapshotPath)
|
||||
if (!inputPath || !existsSync(inputPath)) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
mkdirSync(dirname(targetDbPath), { recursive: true })
|
||||
const restored = await wcdbService.importTableSnapshotWithSchema(
|
||||
job.db.kind,
|
||||
targetDbPath,
|
||||
job.table.name,
|
||||
inputPath,
|
||||
job.table.schemaSql
|
||||
)
|
||||
if (!restored.success) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
inserted += restored.inserted || 0
|
||||
ignored += restored.ignored || 0
|
||||
if (current % 4 === 0) await delay()
|
||||
}
|
||||
|
||||
if (imageJobs.length > 0) {
|
||||
const targetWxid = connected.wxid || String(manifest.source?.wxid || '').trim()
|
||||
const imageKeys = this.getImageKeysForWxid(targetWxid)
|
||||
if (!imageKeys) throw new Error('备份包包含图片资源,但目标账号未配置图片加密密钥')
|
||||
const accountDir = dirname(connected.dbStorage)
|
||||
for (const image of imageJobs) {
|
||||
current += 1
|
||||
emitBackupProgress({
|
||||
phase: 'restoring',
|
||||
message: '正在加密并写回图片资源',
|
||||
current,
|
||||
total: totalRestoreJobs,
|
||||
detail: image.md5 || image.targetRelativePath
|
||||
})
|
||||
const inputPath = this.resolveExtractedPath(extractDir, image.archivePath)
|
||||
const targetPath = this.resolveTargetResourcePath(accountDir, image.targetRelativePath)
|
||||
if (!inputPath || !targetPath || !existsSync(inputPath)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
if (existsSync(targetPath)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
const encrypted = encryptDatViaNative(inputPath, imageKeys.xorKey, imageKeys.aesKey)
|
||||
if (!encrypted) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
mkdirSync(dirname(targetPath), { recursive: true })
|
||||
await writeFile(targetPath, encrypted)
|
||||
if (current % 16 === 0) await delay()
|
||||
}
|
||||
}
|
||||
|
||||
if (plainResourceJobs.length > 0) {
|
||||
const accountDir = dirname(connected.dbStorage)
|
||||
for (const resource of plainResourceJobs) {
|
||||
current += 1
|
||||
emitBackupProgress({
|
||||
phase: 'restoring',
|
||||
message: resource.kind === 'video' ? '正在写回视频资源' : '正在写回文件资源',
|
||||
current,
|
||||
total: totalRestoreJobs,
|
||||
detail: resource.targetRelativePath
|
||||
})
|
||||
const inputPath = this.resolveExtractedPath(extractDir, resource.archivePath)
|
||||
const targetPath = this.resolveTargetResourcePath(accountDir, resource.targetRelativePath)
|
||||
if (!inputPath || !targetPath || !existsSync(inputPath)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
if (existsSync(targetPath)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
mkdirSync(dirname(targetPath), { recursive: true })
|
||||
await copyFile(inputPath, targetPath)
|
||||
if (current % 30 === 0) await delay()
|
||||
}
|
||||
}
|
||||
|
||||
emitBackupProgress({ phase: 'done', message: '载入完成', current: totalRestoreJobs, total: totalRestoreJobs })
|
||||
return { success: true, inserted, ignored, skipped }
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : String(e)
|
||||
emitBackupProgress({ phase: 'failed', message: error })
|
||||
return { success: false, error }
|
||||
} finally {
|
||||
if (extractDir) {
|
||||
try { rmSync(extractDir, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const backupService = new BackupService()
|
||||
@@ -10,6 +10,7 @@ type NativeDecryptResult = {
|
||||
|
||||
type NativeAddon = {
|
||||
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
|
||||
encryptDatNative?: (inputPath: string, xorKey: number, aesKey?: string) => Buffer
|
||||
}
|
||||
|
||||
let cachedAddon: NativeAddon | null | undefined
|
||||
@@ -108,3 +109,19 @@ export function decryptDatViaNative(
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function encryptDatViaNative(
|
||||
inputPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string
|
||||
): Buffer | null {
|
||||
const addon = loadAddon()
|
||||
if (!addon || typeof addon.encryptDatNative !== 'function') return null
|
||||
|
||||
try {
|
||||
const result = addon.encryptDatNative(inputPath, xorKey, aesKey)
|
||||
return Buffer.isBuffer(result) ? result : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '接口未就绪' }
|
||||
|
||||
@@ -366,6 +366,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
|
||||
|
||||
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
|
||||
92
src/types/electron.d.ts
vendored
92
src/types/electron.d.ts
vendored
@@ -21,6 +21,76 @@ 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 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'
|
||||
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
|
||||
}>
|
||||
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 +228,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 }>
|
||||
@@ -1220,4 +1311,3 @@ declare global {
|
||||
}
|
||||
|
||||
export { }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user