diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index 5e593419..c9cd847a 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -7,6 +7,7 @@ import * as fs from 'fs-extra' import * as path from 'path' import WebDav from './WebDav' +import { windowService } from './WindowService' class BackupManager { private tempDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup', 'temp') @@ -72,18 +73,39 @@ class BackupManager { data: string, destinationPath: string = this.backupDir ): Promise { + 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 { await fs.ensureDir(this.tempDir) + onProgress({ stage: 'preparing', progress: 0, total: 100 }) // 将 data 写入临时文件 const tempDataPath = path.join(this.tempDir, 'data.json') await fs.writeFile(tempDataPath, data) + onProgress({ stage: 'writing_data', progress: 20, total: 100 }) // 复制 Data 目录到临时目录 const sourcePath = path.join(app.getPath('userData'), '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) + onProgress({ stage: 'compressing', progress: 80, total: 100 }) // 使用 adm-zip 创建压缩文件 const zip = new AdmZip() @@ -93,6 +115,7 @@ class BackupManager { // 清理临时目录 await fs.remove(this.tempDir) + onProgress({ stage: 'completed', progress: 100, total: 100 }) Logger.log('Backup completed successfully') return backupedFilePath @@ -102,6 +125,44 @@ class BackupManager { } } + private async getDirSize(dirPath: string): Promise { + 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 { + 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 { try { // 创建临时目录 diff --git a/src/renderer/src/components/Popups/BackupPopup.tsx b/src/renderer/src/components/Popups/BackupPopup.tsx new file mode 100644 index 00000000..07ca7991 --- /dev/null +++ b/src/renderer/src/components/Popups/BackupPopup.tsx @@ -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 = ({ resolve }) => { + const [open, setOpen] = useState(true) + const [progressData, setProgressData] = useState() + 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 ( + + {!progressData &&
{t('backup.content')}
} + {progressData && ( +
+ +
{getProgressText()}
+
+ )} +
+ ) +} + +const TopViewKey = 'BackupPopup' + +export default class BackupPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show() { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 8f2ec59c..4ea98a25 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -883,6 +883,20 @@ "quit": "Quit", "show_window": "Show Window", "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" + } } } } diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index f640cb76..6d0044af 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -883,6 +883,20 @@ "quit": "終了", "show_window": "ウィンドウを表示", "visualization": "可視化" + }, + "backup": { + "title": "データバックアップ", + "confirm": "データをバックアップしますか?", + "confirm.button": "バックアップ位置を選択", + "content": "バックアップ操作はすべてのアプリデータを含むため、時間がかかる場合があります。", + "progress": { + "title": "バックアップ進捗", + "preparing": "バックアップ準備中...", + "writing_data": "データ書き込み中...", + "copying_files": "ファイルコピー中... {{progress}}%", + "compressing": "圧縮中...", + "completed": "バックアップ完了" + } } } } diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 37dc7df4..157006b3 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -883,6 +883,20 @@ "quit": "Выйти", "show_window": "Показать окно", "visualization": "Визуализация" + }, + "backup": { + "title": "Резервное копирование данных", + "confirm": "Вы уверены, что хотите создать резервную копию?", + "confirm.button": "Выбрать папку для резервной копии", + "content": "Резервная копия будет содержать все данные приложения, включая чаты, настройки и базу знаний. Это может занять некоторое время.", + "progress": { + "title": "Прогресс резервного копирования", + "preparing": "Подготовка резервной копии...", + "writing_data": "Запись данных...", + "copying_files": "Копирование файлов... {{progress}}%", + "compressing": "Сжатие файлов...", + "completed": "Резервная копия создана" + } } } } diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 7c73cc13..8f0df233 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -883,6 +883,20 @@ "quit": "退出", "show_window": "显示窗口", "visualization": "可视化" + }, + "backup": { + "title": "数据备份", + "confirm": "确定要备份数据吗?", + "confirm.button": "选择备份位置", + "content": "备份操作将涵盖所有应用数据,包括聊天记录、设置、知识库等所有数据。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "progress": { + "title": "备份进度", + "preparing": "准备备份...", + "writing_data": "写入数据...", + "copying_files": "复制文件... {{progress}}%", + "compressing": "压缩文件...", + "completed": "备份完成" + } } } } diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 3e0072c6..6e5c8754 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -883,6 +883,20 @@ "quit": "退出", "show_window": "顯示視窗", "visualization": "可視化" + }, + "backup": { + "title": "資料備份", + "confirm": "確定要備份資料嗎?", + "confirm.button": "選擇備份位置", + "content": "備份操作將涵蓋所有應用資料,包括聊天記錄、設定、知識庫等全部資料。請注意,備份過程可能需要一些時間,感謝您的耐心等待。", + "progress": { + "title": "備份進度", + "preparing": "準備備份...", + "writing_data": "寫入資料...", + "copying_files": "複製文件... {{progress}}%", + "compressing": "壓縮文件...", + "completed": "備份完成" + } } } } diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 90c81854..f0e978cf 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -2,8 +2,9 @@ import { FileSearchOutlined, FolderOpenOutlined, InfoCircleOutlined, SaveOutline import { Client } from '@notionhq/client' import { HStack } from '@renderer/components/Layout' import MinApp from '@renderer/components/MinApp' +import BackupPopup from '@renderer/components/Popups/BackupPopup' 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 { setNotionApiKey, @@ -332,6 +333,8 @@ const DataSettings: FC = () => { }) } + const handleBackup = () => BackupPopup.show() + return ( @@ -340,7 +343,7 @@ const DataSettings: FC = () => { {t('settings.general.backup.title')} -