feat: Add comprehensive backup progress tracking and UI

- Implemented detailed backup progress tracking in BackupManager
- Added new BackupPopup component for backup process visualization
- Enhanced backup process with file copy progress and stage tracking
- Updated localization files with backup progress translations
- Integrated backup progress reporting to renderer process
This commit is contained in:
kangfenmao 2025-03-03 22:22:29 +08:00
parent 581e2fb786
commit e69d0c89a6
8 changed files with 237 additions and 3 deletions

View File

@ -7,6 +7,7 @@ import * as fs from 'fs-extra'
import * as path from 'path' import * as path from 'path'
import WebDav from './WebDav' import WebDav from './WebDav'
import { windowService } from './WindowService'
class BackupManager { class BackupManager {
private tempDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup', 'temp') private tempDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup', 'temp')
@ -72,18 +73,39 @@ class BackupManager {
data: string, data: string,
destinationPath: string = this.backupDir destinationPath: string = this.backupDir
): Promise<string> { ): Promise<string> {
const mainWindow = windowService.getMainWindow()
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send('backup-progress', processData)
Logger.log('[BackupManager] backup progress', processData)
}
try { try {
await fs.ensureDir(this.tempDir) await fs.ensureDir(this.tempDir)
onProgress({ stage: 'preparing', progress: 0, total: 100 })
// 将 data 写入临时文件 // 将 data 写入临时文件
const tempDataPath = path.join(this.tempDir, 'data.json') const tempDataPath = path.join(this.tempDir, 'data.json')
await fs.writeFile(tempDataPath, data) await fs.writeFile(tempDataPath, data)
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
// 复制 Data 目录到临时目录 // 复制 Data 目录到临时目录
const sourcePath = path.join(app.getPath('userData'), 'Data') const sourcePath = path.join(app.getPath('userData'), 'Data')
const tempDataDir = path.join(this.tempDir, 'Data') const tempDataDir = path.join(this.tempDir, 'Data')
await fs.copy(sourcePath, tempDataDir)
// 获取源目录总大小
const totalSize = await this.getDirSize(sourcePath)
let copiedSize = 0
// 使用流式复制
await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
copiedSize += size
const progress = Math.min(80, 20 + Math.floor((copiedSize / totalSize) * 60))
onProgress({ stage: 'copying_files', progress, total: 100 })
})
await this.setWritableRecursive(tempDataDir) await this.setWritableRecursive(tempDataDir)
onProgress({ stage: 'compressing', progress: 80, total: 100 })
// 使用 adm-zip 创建压缩文件 // 使用 adm-zip 创建压缩文件
const zip = new AdmZip() const zip = new AdmZip()
@ -93,6 +115,7 @@ class BackupManager {
// 清理临时目录 // 清理临时目录
await fs.remove(this.tempDir) await fs.remove(this.tempDir)
onProgress({ stage: 'completed', progress: 100, total: 100 })
Logger.log('Backup completed successfully') Logger.log('Backup completed successfully')
return backupedFilePath return backupedFilePath
@ -102,6 +125,44 @@ class BackupManager {
} }
} }
private async getDirSize(dirPath: string): Promise<number> {
let size = 0
const items = await fs.readdir(dirPath, { withFileTypes: true })
for (const item of items) {
const fullPath = path.join(dirPath, item.name)
if (item.isDirectory()) {
size += await this.getDirSize(fullPath)
} else {
const stats = await fs.stat(fullPath)
size += stats.size
}
}
return size
}
private async copyDirWithProgress(
source: string,
destination: string,
onProgress: (size: number) => void
): Promise<void> {
const items = await fs.readdir(source, { withFileTypes: true })
for (const item of items) {
const sourcePath = path.join(source, item.name)
const destPath = path.join(destination, item.name)
if (item.isDirectory()) {
await fs.ensureDir(destPath)
await this.copyDirWithProgress(sourcePath, destPath, onProgress)
} else {
const stats = await fs.stat(sourcePath)
await fs.copy(sourcePath, destPath)
onProgress(stats.size)
}
}
}
async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise<string> { async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise<string> {
try { try {
// 创建临时目录 // 创建临时目录

View File

@ -0,0 +1,100 @@
import { backup } from '@renderer/services/BackupService'
import { Modal, Progress } from 'antd'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { TopView } from '../TopView'
interface Props {
resolve: (data: any) => void
}
interface ProgressData {
stage: string
progress: number
total: number
}
const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [open, setOpen] = useState(true)
const [progressData, setProgressData] = useState<ProgressData>()
const { t } = useTranslation()
useEffect(() => {
const removeListener = window.electron.ipcRenderer.on('backup-progress', (_, data: ProgressData) => {
setProgressData(data)
})
return () => {
removeListener()
}
}, [])
const onOk = async () => {
await backup()
setOpen(false)
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
const getProgressText = () => {
if (!progressData) return ''
if (progressData.stage === 'copying_files') {
return t(`backup.progress.${progressData.stage}`, {
progress: Math.floor(progressData.progress)
})
}
return t(`backup.progress.${progressData.stage}`)
}
BackupPopup.hide = onCancel
return (
<Modal
title={t('backup.title')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
transitionName="ant-move-down"
okText={t('backup.confirm.button')}
centered>
{!progressData && <div>{t('backup.content')}</div>}
{progressData && (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<Progress percent={Math.floor(progressData.progress)} strokeColor="var(--color-primary)" />
<div style={{ marginTop: 16 }}>{getProgressText()}</div>
</div>
)}
</Modal>
)
}
const TopViewKey = 'BackupPopup'
export default class BackupPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show() {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@ -883,6 +883,20 @@
"quit": "Quit", "quit": "Quit",
"show_window": "Show Window", "show_window": "Show Window",
"visualization": "Visualization" "visualization": "Visualization"
},
"backup": {
"title": "Data Backup",
"confirm": "Are you sure you want to backup data?",
"confirm.button": "Select Backup Location",
"content": "Backup operation will cover all application data, including chat history, settings, and knowledge base. Please note that the backup process may take some time, thank you for your patience.",
"progress": {
"title": "Backup Progress",
"preparing": "Preparing backup...",
"writing_data": "Writing data...",
"copying_files": "Copying files... {{progress}}%",
"compressing": "Compressing files...",
"completed": "Backup completed"
}
} }
} }
} }

View File

@ -883,6 +883,20 @@
"quit": "終了", "quit": "終了",
"show_window": "ウィンドウを表示", "show_window": "ウィンドウを表示",
"visualization": "可視化" "visualization": "可視化"
},
"backup": {
"title": "データバックアップ",
"confirm": "データをバックアップしますか?",
"confirm.button": "バックアップ位置を選択",
"content": "バックアップ操作はすべてのアプリデータを含むため、時間がかかる場合があります。",
"progress": {
"title": "バックアップ進捗",
"preparing": "バックアップ準備中...",
"writing_data": "データ書き込み中...",
"copying_files": "ファイルコピー中... {{progress}}%",
"compressing": "圧縮中...",
"completed": "バックアップ完了"
}
} }
} }
} }

View File

@ -883,6 +883,20 @@
"quit": "Выйти", "quit": "Выйти",
"show_window": "Показать окно", "show_window": "Показать окно",
"visualization": "Визуализация" "visualization": "Визуализация"
},
"backup": {
"title": "Резервное копирование данных",
"confirm": "Вы уверены, что хотите создать резервную копию?",
"confirm.button": "Выбрать папку для резервной копии",
"content": "Резервная копия будет содержать все данные приложения, включая чаты, настройки и базу знаний. Это может занять некоторое время.",
"progress": {
"title": "Прогресс резервного копирования",
"preparing": "Подготовка резервной копии...",
"writing_data": "Запись данных...",
"copying_files": "Копирование файлов... {{progress}}%",
"compressing": "Сжатие файлов...",
"completed": "Резервная копия создана"
}
} }
} }
} }

View File

@ -883,6 +883,20 @@
"quit": "退出", "quit": "退出",
"show_window": "显示窗口", "show_window": "显示窗口",
"visualization": "可视化" "visualization": "可视化"
},
"backup": {
"title": "数据备份",
"confirm": "确定要备份数据吗?",
"confirm.button": "选择备份位置",
"content": "备份操作将涵盖所有应用数据,包括聊天记录、设置、知识库等所有数据。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
"progress": {
"title": "备份进度",
"preparing": "准备备份...",
"writing_data": "写入数据...",
"copying_files": "复制文件... {{progress}}%",
"compressing": "压缩文件...",
"completed": "备份完成"
}
} }
} }
} }

View File

@ -883,6 +883,20 @@
"quit": "退出", "quit": "退出",
"show_window": "顯示視窗", "show_window": "顯示視窗",
"visualization": "可視化" "visualization": "可視化"
},
"backup": {
"title": "資料備份",
"confirm": "確定要備份資料嗎?",
"confirm.button": "選擇備份位置",
"content": "備份操作將涵蓋所有應用資料,包括聊天記錄、設定、知識庫等全部資料。請注意,備份過程可能需要一些時間,感謝您的耐心等待。",
"progress": {
"title": "備份進度",
"preparing": "準備備份...",
"writing_data": "寫入資料...",
"copying_files": "複製文件... {{progress}}%",
"compressing": "壓縮文件...",
"completed": "備份完成"
}
} }
} }
} }

View File

@ -2,8 +2,9 @@ import { FileSearchOutlined, FolderOpenOutlined, InfoCircleOutlined, SaveOutline
import { Client } from '@notionhq/client' import { Client } from '@notionhq/client'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import MinApp from '@renderer/components/MinApp' import MinApp from '@renderer/components/MinApp'
import BackupPopup from '@renderer/components/Popups/BackupPopup'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { backup, reset, restore } from '@renderer/services/BackupService' import { reset, restore } from '@renderer/services/BackupService'
import { RootState, useAppDispatch } from '@renderer/store' import { RootState, useAppDispatch } from '@renderer/store'
import { import {
setNotionApiKey, setNotionApiKey,
@ -332,6 +333,8 @@ const DataSettings: FC = () => {
}) })
} }
const handleBackup = () => BackupPopup.show()
return ( return (
<SettingContainer theme={theme}> <SettingContainer theme={theme}>
<SettingGroup theme={theme}> <SettingGroup theme={theme}>
@ -340,7 +343,7 @@ const DataSettings: FC = () => {
<SettingRow> <SettingRow>
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle> <SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
<HStack gap="5px" justifyContent="space-between"> <HStack gap="5px" justifyContent="space-between">
<Button onClick={backup} icon={<SaveOutlined />}> <Button onClick={handleBackup} icon={<SaveOutlined />}>
{t('settings.general.backup.button')} {t('settings.general.backup.button')}
</Button> </Button>
<Button onClick={restore} icon={<FolderOpenOutlined />}> <Button onClick={restore} icon={<FolderOpenOutlined />}>