mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-20 23:22:05 +08:00
feat: implement local cloud directory backup functionality (#6353)
* feat: implement local backup functionality - Added new IPC channels for local backup operations including backup, restore, list, delete, and set directory. - Enhanced BackupManager with methods for handling local backups and integrated auto-sync capabilities. - Updated settings to include local backup configurations and integrated UI components for managing local backups. - Localized new features in English, Japanese, Russian, and Chinese. * refactor: enhance BackupManager and LocalBackupModals for improved file handling - Updated BackupManager to specify the type of result array for better type safety. - Refactored showBackupModal in LocalBackupModals to use useCallback and generate a more descriptive default file name based on device type and timestamp. * refactor: update localBackupDir path in BackupManager for consistency - Changed localBackupDir to use the temp directory instead of userData for better alignment with backup storage practices. * refactor: enforce localBackupDir parameter in BackupManager methods - Updated BackupManager methods to require localBackupDir as a parameter, removing fallback to a default value for improved clarity and consistency in backup operations. - Removed the localBackupDir property from the class, streamlining the backup management process. * fix: update localization strings for improved clarity and consistency - Revised English, Russian, Chinese, and Traditional Chinese localization strings for better user understanding. - Adjusted phrases related to backup and restore processes to enhance clarity. - Standardized terminology across different languages for consistency. * fix: update Chinese localization strings for consistency and clarity - Revised export menu strings in zh-cn.json to improve formatting and consistency. - Removed spaces in phrases for a more streamlined appearance. * feat(settings): add option to disable hardware acceleration - Introduced a new setting to allow users to disable hardware acceleration. - Added corresponding IPC channel and configuration management methods. - Updated UI components to reflect the new setting and prompt for app restart. - Localized confirmation messages for hardware acceleration changes in multiple languages. * udpate migrate * format code * feat(i18n): add localized error messages for backup directory selection - Introduced new error messages for selecting a backup directory in multiple languages, including English, Japanese, Russian, Simplified Chinese, and Traditional Chinese. - Added checks in the LocalBackupSettings component to ensure the selected directory is not the same as the application data or installation paths, and that it has write permissions. * format * update migrate * refactor(LocalBackup): streamline local backup directory validation and enhance settings UI - Removed translation dependency in LocalBackupModals for error handling. - Added comprehensive validation for local backup directory in LocalBackupSettings, including checks for app data path, install path, and write permissions. - Introduced a clear directory button in the settings UI to reset the local backup directory. - Updated the auto-sync logic to account for local backup settings. * refactor(LocalBackupManager): remove redundant error messages for invalid local backup directory - Eliminated repeated error message calls for invalid local backup directory in the LocalBackupManager component. - Streamlined the validation logic to enhance code clarity and maintainability.
This commit is contained in:
parent
00151f2c67
commit
115d2078b9
@ -165,6 +165,11 @@ export enum IpcChannel {
|
|||||||
Backup_CheckConnection = 'backup:checkConnection',
|
Backup_CheckConnection = 'backup:checkConnection',
|
||||||
Backup_CreateDirectory = 'backup:createDirectory',
|
Backup_CreateDirectory = 'backup:createDirectory',
|
||||||
Backup_DeleteWebdavFile = 'backup:deleteWebdavFile',
|
Backup_DeleteWebdavFile = 'backup:deleteWebdavFile',
|
||||||
|
Backup_BackupToLocalDir = 'backup:backupToLocalDir',
|
||||||
|
Backup_RestoreFromLocalBackup = 'backup:restoreFromLocalBackup',
|
||||||
|
Backup_ListLocalBackupFiles = 'backup:listLocalBackupFiles',
|
||||||
|
Backup_DeleteLocalBackupFile = 'backup:deleteLocalBackupFile',
|
||||||
|
Backup_SetLocalBackupDir = 'backup:setLocalBackupDir',
|
||||||
Backup_BackupToS3 = 'backup:backupToS3',
|
Backup_BackupToS3 = 'backup:backupToS3',
|
||||||
Backup_RestoreFromS3 = 'backup:restoreFromS3',
|
Backup_RestoreFromS3 = 'backup:restoreFromS3',
|
||||||
Backup_ListS3Files = 'backup:listS3Files',
|
Backup_ListS3Files = 'backup:listS3Files',
|
||||||
|
|||||||
@ -365,6 +365,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection)
|
ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection)
|
||||||
ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory)
|
ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory)
|
||||||
ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile)
|
ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile)
|
||||||
|
ipcMain.handle(IpcChannel.Backup_BackupToLocalDir, backupManager.backupToLocalDir)
|
||||||
|
ipcMain.handle(IpcChannel.Backup_RestoreFromLocalBackup, backupManager.restoreFromLocalBackup)
|
||||||
|
ipcMain.handle(IpcChannel.Backup_ListLocalBackupFiles, backupManager.listLocalBackupFiles)
|
||||||
|
ipcMain.handle(IpcChannel.Backup_DeleteLocalBackupFile, backupManager.deleteLocalBackupFile)
|
||||||
|
ipcMain.handle(IpcChannel.Backup_SetLocalBackupDir, backupManager.setLocalBackupDir)
|
||||||
ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3)
|
ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3)
|
||||||
ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3)
|
ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3)
|
||||||
ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files)
|
ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files)
|
||||||
|
|||||||
@ -27,6 +27,11 @@ class BackupManager {
|
|||||||
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
|
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
|
||||||
this.listWebdavFiles = this.listWebdavFiles.bind(this)
|
this.listWebdavFiles = this.listWebdavFiles.bind(this)
|
||||||
this.deleteWebdavFile = this.deleteWebdavFile.bind(this)
|
this.deleteWebdavFile = this.deleteWebdavFile.bind(this)
|
||||||
|
this.listLocalBackupFiles = this.listLocalBackupFiles.bind(this)
|
||||||
|
this.deleteLocalBackupFile = this.deleteLocalBackupFile.bind(this)
|
||||||
|
this.backupToLocalDir = this.backupToLocalDir.bind(this)
|
||||||
|
this.restoreFromLocalBackup = this.restoreFromLocalBackup.bind(this)
|
||||||
|
this.setLocalBackupDir = this.setLocalBackupDir.bind(this)
|
||||||
this.backupToS3 = this.backupToS3.bind(this)
|
this.backupToS3 = this.backupToS3.bind(this)
|
||||||
this.restoreFromS3 = this.restoreFromS3.bind(this)
|
this.restoreFromS3 = this.restoreFromS3.bind(this)
|
||||||
this.listS3Files = this.listS3Files.bind(this)
|
this.listS3Files = this.listS3Files.bind(this)
|
||||||
@ -477,6 +482,28 @@ class BackupManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async backupToLocalDir(
|
||||||
|
_: Electron.IpcMainInvokeEvent,
|
||||||
|
data: string,
|
||||||
|
fileName: string,
|
||||||
|
localConfig: {
|
||||||
|
localBackupDir: string
|
||||||
|
skipBackupFile: boolean
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const backupDir = localConfig.localBackupDir
|
||||||
|
// Create backup directory if it doesn't exist
|
||||||
|
await fs.ensureDir(backupDir)
|
||||||
|
|
||||||
|
const backupedFilePath = await this.backup(_, fileName, data, backupDir, localConfig.skipBackupFile)
|
||||||
|
return backupedFilePath
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[BackupManager] Local backup failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) {
|
async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) {
|
||||||
const os = require('os')
|
const os = require('os')
|
||||||
const deviceName = os.hostname ? os.hostname() : 'device'
|
const deviceName = os.hostname ? os.hostname() : 'device'
|
||||||
@ -504,6 +531,75 @@ class BackupManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async restoreFromLocalBackup(_: Electron.IpcMainInvokeEvent, fileName: string, localBackupDir: string) {
|
||||||
|
try {
|
||||||
|
const backupDir = localBackupDir
|
||||||
|
const backupPath = path.join(backupDir, fileName)
|
||||||
|
|
||||||
|
if (!fs.existsSync(backupPath)) {
|
||||||
|
throw new Error(`Backup file not found: ${backupPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.restore(_, backupPath)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[BackupManager] Local restore failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listLocalBackupFiles(_: Electron.IpcMainInvokeEvent, localBackupDir: string) {
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(localBackupDir)
|
||||||
|
const result: Array<{ fileName: string; modifiedTime: string; size: number }> = []
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(localBackupDir, file)
|
||||||
|
const stat = await fs.stat(filePath)
|
||||||
|
|
||||||
|
if (stat.isFile() && file.endsWith('.zip')) {
|
||||||
|
result.push({
|
||||||
|
fileName: file,
|
||||||
|
modifiedTime: stat.mtime.toISOString(),
|
||||||
|
size: stat.size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by modified time, newest first
|
||||||
|
return result.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime())
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[BackupManager] List local backup files failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLocalBackupFile(_: Electron.IpcMainInvokeEvent, fileName: string, localBackupDir: string) {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(localBackupDir, fileName)
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error(`Backup file not found: ${filePath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.remove(filePath)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[BackupManager] Delete local backup file failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setLocalBackupDir(_: Electron.IpcMainInvokeEvent, dirPath: string) {
|
||||||
|
try {
|
||||||
|
// Check if directory exists
|
||||||
|
await fs.ensureDir(dirPath)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[BackupManager] Set local backup directory failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async restoreFromS3(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
|
async restoreFromS3(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
|
||||||
const filename = s3Config.fileName || 'cherry-studio.backup.zip'
|
const filename = s3Config.fileName || 'cherry-studio.backup.zip'
|
||||||
|
|
||||||
|
|||||||
@ -88,6 +88,18 @@ const api = {
|
|||||||
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options),
|
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options),
|
||||||
deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) =>
|
deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) =>
|
||||||
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig),
|
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig),
|
||||||
|
backupToLocalDir: (
|
||||||
|
data: string,
|
||||||
|
fileName: string,
|
||||||
|
localConfig: { localBackupDir?: string; skipBackupFile?: boolean }
|
||||||
|
) => ipcRenderer.invoke(IpcChannel.Backup_BackupToLocalDir, data, fileName, localConfig),
|
||||||
|
restoreFromLocalBackup: (fileName: string, localBackupDir?: string) =>
|
||||||
|
ipcRenderer.invoke(IpcChannel.Backup_RestoreFromLocalBackup, fileName, localBackupDir),
|
||||||
|
listLocalBackupFiles: (localBackupDir?: string) =>
|
||||||
|
ipcRenderer.invoke(IpcChannel.Backup_ListLocalBackupFiles, localBackupDir),
|
||||||
|
deleteLocalBackupFile: (fileName: string, localBackupDir?: string) =>
|
||||||
|
ipcRenderer.invoke(IpcChannel.Backup_DeleteLocalBackupFile, fileName, localBackupDir),
|
||||||
|
setLocalBackupDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.Backup_SetLocalBackupDir, dirPath),
|
||||||
checkWebdavConnection: (webdavConfig: WebDavConfig) =>
|
checkWebdavConnection: (webdavConfig: WebDavConfig) =>
|
||||||
ipcRenderer.invoke(IpcChannel.Backup_CheckConnection, webdavConfig),
|
ipcRenderer.invoke(IpcChannel.Backup_CheckConnection, webdavConfig),
|
||||||
|
|
||||||
|
|||||||
255
src/renderer/src/components/LocalBackupManager.tsx
Normal file
255
src/renderer/src/components/LocalBackupManager.tsx
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||||
|
import { restoreFromLocalBackup } from '@renderer/services/BackupService'
|
||||||
|
import { formatFileSize } from '@renderer/utils'
|
||||||
|
import { Button, message, Modal, Table, Tooltip } from 'antd'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface BackupFile {
|
||||||
|
fileName: string
|
||||||
|
modifiedTime: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocalBackupManagerProps {
|
||||||
|
visible: boolean
|
||||||
|
onClose: () => void
|
||||||
|
localBackupDir?: string
|
||||||
|
restoreMethod?: (fileName: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMethod }: LocalBackupManagerProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [restoring, setRestoring] = useState(false)
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 5,
|
||||||
|
total: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchBackupFiles = useCallback(async () => {
|
||||||
|
if (!localBackupDir) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const files = await window.api.backup.listLocalBackupFiles(localBackupDir)
|
||||||
|
setBackupFiles(files)
|
||||||
|
setPagination((prev) => ({
|
||||||
|
...prev,
|
||||||
|
total: files.length
|
||||||
|
}))
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(`${t('settings.data.local.backup.manager.fetch.error')}: ${error.message}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [localBackupDir, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
fetchBackupFiles()
|
||||||
|
setSelectedRowKeys([])
|
||||||
|
setPagination((prev) => ({
|
||||||
|
...prev,
|
||||||
|
current: 1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [visible, fetchBackupFiles])
|
||||||
|
|
||||||
|
const handleTableChange = (pagination: any) => {
|
||||||
|
setPagination(pagination)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteSelected = async () => {
|
||||||
|
if (selectedRowKeys.length === 0) {
|
||||||
|
message.warning(t('settings.data.local.backup.manager.select.files.delete'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!localBackupDir) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.modal.confirm({
|
||||||
|
title: t('settings.data.local.backup.manager.delete.confirm.title'),
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
content: t('settings.data.local.backup.manager.delete.confirm.multiple', { count: selectedRowKeys.length }),
|
||||||
|
okText: t('common.confirm'),
|
||||||
|
cancelText: t('common.cancel'),
|
||||||
|
centered: true,
|
||||||
|
onOk: async () => {
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
// Delete selected files one by one
|
||||||
|
for (const key of selectedRowKeys) {
|
||||||
|
await window.api.backup.deleteLocalBackupFile(key.toString(), localBackupDir)
|
||||||
|
}
|
||||||
|
message.success(
|
||||||
|
t('settings.data.local.backup.manager.delete.success.multiple', { count: selectedRowKeys.length })
|
||||||
|
)
|
||||||
|
setSelectedRowKeys([])
|
||||||
|
await fetchBackupFiles()
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(`${t('settings.data.local.backup.manager.delete.error')}: ${error.message}`)
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteSingle = async (fileName: string) => {
|
||||||
|
if (!localBackupDir) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.modal.confirm({
|
||||||
|
title: t('settings.data.local.backup.manager.delete.confirm.title'),
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
content: t('settings.data.local.backup.manager.delete.confirm.single', { fileName }),
|
||||||
|
okText: t('common.confirm'),
|
||||||
|
cancelText: t('common.cancel'),
|
||||||
|
centered: true,
|
||||||
|
onOk: async () => {
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await window.api.backup.deleteLocalBackupFile(fileName, localBackupDir)
|
||||||
|
message.success(t('settings.data.local.backup.manager.delete.success.single'))
|
||||||
|
await fetchBackupFiles()
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(`${t('settings.data.local.backup.manager.delete.error')}: ${error.message}`)
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async (fileName: string) => {
|
||||||
|
if (!localBackupDir) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.modal.confirm({
|
||||||
|
title: t('settings.data.local.restore.confirm.title'),
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
content: t('settings.data.local.restore.confirm.content'),
|
||||||
|
okText: t('common.confirm'),
|
||||||
|
cancelText: t('common.cancel'),
|
||||||
|
centered: true,
|
||||||
|
onOk: async () => {
|
||||||
|
setRestoring(true)
|
||||||
|
try {
|
||||||
|
await (restoreMethod || restoreFromLocalBackup)(fileName)
|
||||||
|
message.success(t('settings.data.local.backup.manager.restore.success'))
|
||||||
|
onClose() // Close the modal
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(`${t('settings.data.local.backup.manager.restore.error')}: ${error.message}`)
|
||||||
|
} finally {
|
||||||
|
setRestoring(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: t('settings.data.local.backup.manager.columns.fileName'),
|
||||||
|
dataIndex: 'fileName',
|
||||||
|
key: 'fileName',
|
||||||
|
ellipsis: {
|
||||||
|
showTitle: false
|
||||||
|
},
|
||||||
|
render: (fileName: string) => (
|
||||||
|
<Tooltip placement="topLeft" title={fileName}>
|
||||||
|
{fileName}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('settings.data.local.backup.manager.columns.modifiedTime'),
|
||||||
|
dataIndex: 'modifiedTime',
|
||||||
|
key: 'modifiedTime',
|
||||||
|
width: 180,
|
||||||
|
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('settings.data.local.backup.manager.columns.size'),
|
||||||
|
dataIndex: 'size',
|
||||||
|
key: 'size',
|
||||||
|
width: 120,
|
||||||
|
render: (size: number) => formatFileSize(size)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('settings.data.local.backup.manager.columns.actions'),
|
||||||
|
key: 'action',
|
||||||
|
width: 160,
|
||||||
|
render: (_: any, record: BackupFile) => (
|
||||||
|
<>
|
||||||
|
<Button type="link" onClick={() => handleRestore(record.fileName)} disabled={restoring || deleting}>
|
||||||
|
{t('settings.data.local.backup.manager.restore.text')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
onClick={() => handleDeleteSingle(record.fileName)}
|
||||||
|
disabled={deleting || restoring}>
|
||||||
|
{t('settings.data.local.backup.manager.delete.text')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const rowSelection = {
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (selectedRowKeys: React.Key[]) => {
|
||||||
|
setSelectedRowKeys(selectedRowKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('settings.data.local.backup.manager.title')}
|
||||||
|
open={visible}
|
||||||
|
onCancel={onClose}
|
||||||
|
width={800}
|
||||||
|
centered
|
||||||
|
transitionName="animation-move-down"
|
||||||
|
footer={[
|
||||||
|
<Button key="refresh" icon={<ReloadOutlined />} onClick={fetchBackupFiles} disabled={loading}>
|
||||||
|
{t('settings.data.local.backup.manager.refresh')}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="delete"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={handleDeleteSelected}
|
||||||
|
disabled={selectedRowKeys.length === 0 || deleting}
|
||||||
|
loading={deleting}>
|
||||||
|
{t('settings.data.local.backup.manager.delete.selected')} ({selectedRowKeys.length})
|
||||||
|
</Button>,
|
||||||
|
<Button key="close" onClick={onClose}>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
]}>
|
||||||
|
<Table
|
||||||
|
rowKey="fileName"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={backupFiles}
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
pagination={pagination}
|
||||||
|
loading={loading}
|
||||||
|
onChange={handleTableChange}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
src/renderer/src/components/LocalBackupModals.tsx
Normal file
98
src/renderer/src/components/LocalBackupModals.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { backupToLocalDir } from '@renderer/services/BackupService'
|
||||||
|
import { Button, Input, Modal } from 'antd'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface LocalBackupModalProps {
|
||||||
|
isModalVisible: boolean
|
||||||
|
handleBackup: () => void
|
||||||
|
handleCancel: () => void
|
||||||
|
backuping: boolean
|
||||||
|
customFileName: string
|
||||||
|
setCustomFileName: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocalBackupModal({
|
||||||
|
isModalVisible,
|
||||||
|
handleBackup,
|
||||||
|
handleCancel,
|
||||||
|
backuping,
|
||||||
|
customFileName,
|
||||||
|
setCustomFileName
|
||||||
|
}: LocalBackupModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('settings.data.local.backup.modal.title')}
|
||||||
|
open={isModalVisible}
|
||||||
|
onOk={handleBackup}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
footer={[
|
||||||
|
<Button key="back" onClick={handleCancel}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>,
|
||||||
|
<Button key="submit" type="primary" loading={backuping} onClick={handleBackup}>
|
||||||
|
{t('common.confirm')}
|
||||||
|
</Button>
|
||||||
|
]}>
|
||||||
|
<Input
|
||||||
|
value={customFileName}
|
||||||
|
onChange={(e) => setCustomFileName(e.target.value)}
|
||||||
|
placeholder={t('settings.data.local.backup.modal.filename.placeholder')}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook for backup modal
|
||||||
|
export function useLocalBackupModal(localBackupDir: string | undefined) {
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false)
|
||||||
|
const [backuping, setBackuping] = useState(false)
|
||||||
|
const [customFileName, setCustomFileName] = useState('')
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsModalVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showBackupModal = useCallback(async () => {
|
||||||
|
// 获取默认文件名
|
||||||
|
const deviceType = await window.api.system.getDeviceType()
|
||||||
|
const hostname = await window.api.system.getHostname()
|
||||||
|
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||||
|
const defaultFileName = `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
|
||||||
|
setCustomFileName(defaultFileName)
|
||||||
|
setIsModalVisible(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleBackup = async () => {
|
||||||
|
if (!localBackupDir) {
|
||||||
|
setIsModalVisible(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setBackuping(true)
|
||||||
|
try {
|
||||||
|
await backupToLocalDir({
|
||||||
|
showMessage: true,
|
||||||
|
customFileName
|
||||||
|
})
|
||||||
|
setIsModalVisible(false)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LocalBackupModal] Backup failed:', error)
|
||||||
|
} finally {
|
||||||
|
setBackuping(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isModalVisible,
|
||||||
|
handleBackup,
|
||||||
|
handleCancel,
|
||||||
|
backuping,
|
||||||
|
customFileName,
|
||||||
|
setCustomFileName,
|
||||||
|
showBackupModal
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -374,6 +374,7 @@
|
|||||||
"assistant": "Assistant",
|
"assistant": "Assistant",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
|
"browse": "Browse",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"chat": "Chat",
|
"chat": "Chat",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
@ -1266,7 +1267,52 @@
|
|||||||
"syncStatus": "Backup Status",
|
"syncStatus": "Backup Status",
|
||||||
"title": "WebDAV",
|
"title": "WebDAV",
|
||||||
"user": "WebDAV User",
|
"user": "WebDAV User",
|
||||||
"maxBackups": "Maximum Backups",
|
"maxBackups": "Maximum Backups"
|
||||||
|
},
|
||||||
|
"local": {
|
||||||
|
"autoSync": "Auto Backup",
|
||||||
|
"autoSync.off": "Off",
|
||||||
|
"backup.button": "Backup to Local",
|
||||||
|
"backup.modal.filename.placeholder": "Please enter backup filename",
|
||||||
|
"backup.modal.title": "Backup to Local Directory",
|
||||||
|
"backup.manager.title": "Local Backup Manager",
|
||||||
|
"backup.manager.refresh": "Refresh",
|
||||||
|
"backup.manager.delete.selected": "Delete Selected",
|
||||||
|
"backup.manager.delete.text": "Delete",
|
||||||
|
"backup.manager.restore.text": "Restore",
|
||||||
|
"backup.manager.restore.success": "Restore successful, application will refresh shortly",
|
||||||
|
"backup.manager.restore.error": "Restore failed",
|
||||||
|
"backup.manager.delete.confirm.title": "Confirm Delete",
|
||||||
|
"backup.manager.delete.confirm.single": "Are you sure you want to delete backup file \"{{fileName}}\"? This action cannot be undone.",
|
||||||
|
"backup.manager.delete.confirm.multiple": "Are you sure you want to delete {{count}} selected backup files? This action cannot be undone.",
|
||||||
|
"backup.manager.delete.success.single": "Deleted successfully",
|
||||||
|
"backup.manager.delete.success.multiple": "Successfully deleted {{count}} backup files",
|
||||||
|
"backup.manager.delete.error": "Delete failed",
|
||||||
|
"backup.manager.fetch.error": "Failed to get backup files",
|
||||||
|
"backup.manager.select.files.delete": "Please select backup files to delete",
|
||||||
|
"backup.manager.columns.fileName": "Filename",
|
||||||
|
"backup.manager.columns.modifiedTime": "Modified Time",
|
||||||
|
"backup.manager.columns.size": "Size",
|
||||||
|
"backup.manager.columns.actions": "Actions",
|
||||||
|
"directory.select_error_app_data_path": "New path cannot be the same as the application data path",
|
||||||
|
"directory.select_error_in_app_install_path": "New path cannot be the same as the application installation path",
|
||||||
|
"directory.select_error_write_permission": "New path does not have write permission",
|
||||||
|
"directory.select_title": "Select Backup Directory",
|
||||||
|
"directory": "Local Backup Directory",
|
||||||
|
"directory.placeholder": "Select a directory for local backups",
|
||||||
|
"hour_interval_one": "{{count}} hour",
|
||||||
|
"hour_interval_other": "{{count}} hours",
|
||||||
|
"lastSync": "Last Backup",
|
||||||
|
"minute_interval_one": "{{count}} minute",
|
||||||
|
"minute_interval_other": "{{count}} minutes",
|
||||||
|
"noSync": "Waiting for next backup",
|
||||||
|
"restore.button": "Restore from Local",
|
||||||
|
"restore.confirm.content": "Restoring from local backup will replace current data. Do you want to continue?",
|
||||||
|
"restore.confirm.title": "Confirm Restore",
|
||||||
|
"syncError": "Backup Error",
|
||||||
|
"syncStatus": "Backup Status",
|
||||||
|
"title": "Local Backup",
|
||||||
|
"maxBackups": "Maximum backups",
|
||||||
"maxBackups.unlimited": "Unlimited"
|
"maxBackups.unlimited": "Unlimited"
|
||||||
},
|
},
|
||||||
"s3": {
|
"s3": {
|
||||||
|
|||||||
@ -374,6 +374,7 @@
|
|||||||
"assistant": "アシスタント",
|
"assistant": "アシスタント",
|
||||||
"avatar": "アバター",
|
"avatar": "アバター",
|
||||||
"back": "戻る",
|
"back": "戻る",
|
||||||
|
"browse": "参照",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"chat": "チャット",
|
"chat": "チャット",
|
||||||
"clear": "クリア",
|
"clear": "クリア",
|
||||||
@ -1157,6 +1158,50 @@
|
|||||||
"divider.third_party": "サードパーティー連携",
|
"divider.third_party": "サードパーティー連携",
|
||||||
"hour_interval_one": "{{count}} 時間",
|
"hour_interval_one": "{{count}} 時間",
|
||||||
"hour_interval_other": "{{count}} 時間",
|
"hour_interval_other": "{{count}} 時間",
|
||||||
|
"local": {
|
||||||
|
"title": "ローカルバックアップ",
|
||||||
|
"directory": "バックアップディレクトリ",
|
||||||
|
"directory.placeholder": "バックアップディレクトリを選択してください",
|
||||||
|
"backup.button": "ローカルにバックアップ",
|
||||||
|
"backup.modal.title": "ローカルにバックアップ",
|
||||||
|
"backup.modal.filename.placeholder": "バックアップファイル名を入力してください",
|
||||||
|
"restore.button": "バックアップファイル管理",
|
||||||
|
"autoSync": "自動バックアップ",
|
||||||
|
"autoSync.off": "オフ",
|
||||||
|
"lastSync": "最終バックアップ",
|
||||||
|
"noSync": "次回のバックアップを待機中",
|
||||||
|
"syncError": "バックアップエラー",
|
||||||
|
"syncStatus": "バックアップ状態",
|
||||||
|
"minute_interval_one": "{{count}} 分",
|
||||||
|
"minute_interval_other": "{{count}} 分",
|
||||||
|
"hour_interval_one": "{{count}} 時間",
|
||||||
|
"hour_interval_other": "{{count}} 時間",
|
||||||
|
"maxBackups": "最大バックアップ数",
|
||||||
|
"maxBackups.unlimited": "無制限",
|
||||||
|
"backup.manager.title": "バックアップファイル管理",
|
||||||
|
"backup.manager.refresh": "更新",
|
||||||
|
"backup.manager.delete.selected": "選択したものを削除",
|
||||||
|
"backup.manager.delete.text": "削除",
|
||||||
|
"backup.manager.restore.text": "復元",
|
||||||
|
"backup.manager.restore.success": "復元が成功しました、アプリケーションは間もなく更新されます",
|
||||||
|
"backup.manager.restore.error": "復元に失敗しました",
|
||||||
|
"backup.manager.delete.confirm.title": "削除の確認",
|
||||||
|
"backup.manager.delete.confirm.single": "バックアップファイル \"{{fileName}}\" を削除してもよろしいですか?この操作は元に戻せません。",
|
||||||
|
"backup.manager.delete.confirm.multiple": "選択した {{count}} 個のバックアップファイルを削除してもよろしいですか?この操作は元に戻せません。",
|
||||||
|
"backup.manager.delete.success.single": "削除が成功しました",
|
||||||
|
"backup.manager.delete.success.multiple": "{{count}} 個のバックアップファイルを削除しました",
|
||||||
|
"backup.manager.delete.error": "削除に失敗しました",
|
||||||
|
"backup.manager.fetch.error": "バックアップファイルの取得に失敗しました",
|
||||||
|
"backup.manager.select.files.delete": "削除するバックアップファイルを選択してください",
|
||||||
|
"backup.manager.columns.fileName": "ファイル名",
|
||||||
|
"backup.manager.columns.modifiedTime": "更新日時",
|
||||||
|
"backup.manager.columns.size": "サイズ",
|
||||||
|
"backup.manager.columns.actions": "操作",
|
||||||
|
"directory.select_error_app_data_path": "新パスはアプリデータパスと同じです。別のパスを選択してください",
|
||||||
|
"directory.select_error_in_app_install_path": "新パスはアプリインストールパスと同じです。別のパスを選択してください",
|
||||||
|
"directory.select_error_write_permission": "新パスに書き込み権限がありません",
|
||||||
|
"directory.select_title": "バックアップディレクトリを選択"
|
||||||
|
},
|
||||||
"export_menu": {
|
"export_menu": {
|
||||||
"title": "エクスポートメニュー設定",
|
"title": "エクスポートメニュー設定",
|
||||||
"image": "画像としてエクスポート",
|
"image": "画像としてエクスポート",
|
||||||
|
|||||||
@ -374,6 +374,7 @@
|
|||||||
"assistant": "Ассистент",
|
"assistant": "Ассистент",
|
||||||
"avatar": "Аватар",
|
"avatar": "Аватар",
|
||||||
"back": "Назад",
|
"back": "Назад",
|
||||||
|
"browse": "Обзор",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"chat": "Чат",
|
"chat": "Чат",
|
||||||
"clear": "Очистить",
|
"clear": "Очистить",
|
||||||
@ -1157,6 +1158,52 @@
|
|||||||
"divider.third_party": "Сторонние подключения",
|
"divider.third_party": "Сторонние подключения",
|
||||||
"hour_interval_one": "{{count}} час",
|
"hour_interval_one": "{{count}} час",
|
||||||
"hour_interval_other": "{{count}} часов",
|
"hour_interval_other": "{{count}} часов",
|
||||||
|
"local": {
|
||||||
|
"title": "Локальное резервное копирование",
|
||||||
|
"directory": "Каталог резервных копий",
|
||||||
|
"directory.placeholder": "Выберите каталог для резервных копий",
|
||||||
|
"backup.button": "Создать резервную копию",
|
||||||
|
"backup.modal.title": "Локальное резервное копирование",
|
||||||
|
"backup.modal.filename.placeholder": "Введите имя файла резервной копии",
|
||||||
|
"restore.button": "Управление резервными копиями",
|
||||||
|
"autoSync": "Автоматическое резервное копирование",
|
||||||
|
"autoSync.off": "Выключено",
|
||||||
|
"lastSync": "Последнее копирование",
|
||||||
|
"noSync": "Ожидание следующего копирования",
|
||||||
|
"syncError": "Ошибка копирования",
|
||||||
|
"syncStatus": "Статус копирования",
|
||||||
|
"minute_interval_one": "{{count}} минута",
|
||||||
|
"minute_interval_few": "{{count}} минуты",
|
||||||
|
"minute_interval_many": "{{count}} минут",
|
||||||
|
"hour_interval_one": "{{count}} час",
|
||||||
|
"hour_interval_few": "{{count}} часа",
|
||||||
|
"hour_interval_many": "{{count}} часов",
|
||||||
|
"maxBackups": "Максимальное количество резервных копий",
|
||||||
|
"maxBackups.unlimited": "Без ограничений",
|
||||||
|
"backup.manager.title": "Управление резервными копиями",
|
||||||
|
"backup.manager.refresh": "Обновить",
|
||||||
|
"backup.manager.delete.selected": "Удалить выбранное",
|
||||||
|
"backup.manager.delete.text": "Удалить",
|
||||||
|
"backup.manager.restore.text": "Восстановить",
|
||||||
|
"backup.manager.restore.success": "Восстановление успешно, приложение скоро обновится",
|
||||||
|
"backup.manager.restore.error": "Ошибка восстановления",
|
||||||
|
"backup.manager.delete.confirm.title": "Подтверждение удаления",
|
||||||
|
"backup.manager.delete.confirm.single": "Вы действительно хотите удалить файл резервной копии \"{{fileName}}\"? Это действие нельзя отменить.",
|
||||||
|
"backup.manager.delete.confirm.multiple": "Вы действительно хотите удалить выбранные {{count}} файла(ов) резервных копий? Это действие нельзя отменить.",
|
||||||
|
"backup.manager.delete.success.single": "Успешно удалено",
|
||||||
|
"backup.manager.delete.success.multiple": "Удалено {{count}} файла(ов) резервных копий",
|
||||||
|
"backup.manager.delete.error": "Ошибка удаления",
|
||||||
|
"backup.manager.fetch.error": "Ошибка получения файлов резервных копий",
|
||||||
|
"backup.manager.select.files.delete": "Выберите файлы резервных копий для удаления",
|
||||||
|
"backup.manager.columns.fileName": "Имя файла",
|
||||||
|
"backup.manager.columns.modifiedTime": "Время изменения",
|
||||||
|
"backup.manager.columns.size": "Размер",
|
||||||
|
"backup.manager.columns.actions": "Действия",
|
||||||
|
"directory.select_error_app_data_path": "Новый путь не может совпадать с путем данных приложения",
|
||||||
|
"directory.select_error_in_app_install_path": "Новый путь не может совпадать с путем установки приложения",
|
||||||
|
"directory.select_error_write_permission": "Новый путь не имеет разрешения на запись",
|
||||||
|
"directory.select_title": "Выберите каталог для резервных копий"
|
||||||
|
},
|
||||||
"export_menu": {
|
"export_menu": {
|
||||||
"title": "Настройки меню экспорта",
|
"title": "Настройки меню экспорта",
|
||||||
"image": "Экспорт как изображение",
|
"image": "Экспорт как изображение",
|
||||||
|
|||||||
@ -374,6 +374,7 @@
|
|||||||
"assistant": "智能体",
|
"assistant": "智能体",
|
||||||
"avatar": "头像",
|
"avatar": "头像",
|
||||||
"back": "返回",
|
"back": "返回",
|
||||||
|
"browse": "浏览",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"chat": "聊天",
|
"chat": "聊天",
|
||||||
"clear": "清除",
|
"clear": "清除",
|
||||||
@ -1159,6 +1160,50 @@
|
|||||||
"divider.third_party": "第三方连接",
|
"divider.third_party": "第三方连接",
|
||||||
"hour_interval_one": "{{count}} 小时",
|
"hour_interval_one": "{{count}} 小时",
|
||||||
"hour_interval_other": "{{count}} 小时",
|
"hour_interval_other": "{{count}} 小时",
|
||||||
|
"local": {
|
||||||
|
"title": "本地备份",
|
||||||
|
"directory": "备份目录",
|
||||||
|
"directory.placeholder": "请选择备份目录",
|
||||||
|
"backup.button": "本地备份",
|
||||||
|
"backup.modal.title": "本地备份",
|
||||||
|
"backup.modal.filename.placeholder": "请输入备份文件名",
|
||||||
|
"restore.button": "备份文件管理",
|
||||||
|
"autoSync": "自动备份",
|
||||||
|
"autoSync.off": "关闭",
|
||||||
|
"lastSync": "上次备份",
|
||||||
|
"noSync": "等待下次备份",
|
||||||
|
"syncError": "备份错误",
|
||||||
|
"syncStatus": "备份状态",
|
||||||
|
"minute_interval_one": "{{count}} 分钟",
|
||||||
|
"minute_interval_other": "{{count}} 分钟",
|
||||||
|
"hour_interval_one": "{{count}} 小时",
|
||||||
|
"hour_interval_other": "{{count}} 小时",
|
||||||
|
"maxBackups": "最大备份数",
|
||||||
|
"maxBackups.unlimited": "无限制",
|
||||||
|
"backup.manager.title": "备份文件管理",
|
||||||
|
"backup.manager.refresh": "刷新",
|
||||||
|
"backup.manager.delete.selected": "删除选中",
|
||||||
|
"backup.manager.delete.text": "删除",
|
||||||
|
"backup.manager.restore.text": "恢复",
|
||||||
|
"backup.manager.restore.success": "恢复成功,应用将很快刷新",
|
||||||
|
"backup.manager.restore.error": "恢复失败",
|
||||||
|
"backup.manager.delete.confirm.title": "确认删除",
|
||||||
|
"backup.manager.delete.confirm.single": "确定要删除备份文件 \"{{fileName}}\" 吗?此操作无法撤销。",
|
||||||
|
"backup.manager.delete.confirm.multiple": "确定要删除选中的 {{count}} 个备份文件吗?此操作无法撤销。",
|
||||||
|
"backup.manager.delete.success.single": "删除成功",
|
||||||
|
"backup.manager.delete.success.multiple": "已删除 {{count}} 个备份文件",
|
||||||
|
"backup.manager.delete.error": "删除失败",
|
||||||
|
"backup.manager.fetch.error": "获取备份文件失败",
|
||||||
|
"backup.manager.select.files.delete": "请选择要删除的备份文件",
|
||||||
|
"backup.manager.columns.fileName": "文件名",
|
||||||
|
"backup.manager.columns.modifiedTime": "修改时间",
|
||||||
|
"backup.manager.columns.size": "大小",
|
||||||
|
"backup.manager.columns.actions": "操作",
|
||||||
|
"directory.select_error_app_data_path": "新路径不能与应用数据路径相同",
|
||||||
|
"directory.select_error_in_app_install_path": "新路径不能与应用安装路径相同",
|
||||||
|
"directory.select_error_write_permission": "新路径没有写入权限",
|
||||||
|
"directory.select_title": "选择备份目录"
|
||||||
|
},
|
||||||
"export_menu": {
|
"export_menu": {
|
||||||
"title": "导出菜单设置",
|
"title": "导出菜单设置",
|
||||||
"image": "导出为图片",
|
"image": "导出为图片",
|
||||||
|
|||||||
@ -374,6 +374,7 @@
|
|||||||
"assistant": "智慧代理人",
|
"assistant": "智慧代理人",
|
||||||
"avatar": "頭像",
|
"avatar": "頭像",
|
||||||
"back": "返回",
|
"back": "返回",
|
||||||
|
"browse": "瀏覽",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"chat": "聊天",
|
"chat": "聊天",
|
||||||
"clear": "清除",
|
"clear": "清除",
|
||||||
@ -1159,6 +1160,50 @@
|
|||||||
"divider.third_party": "第三方連接",
|
"divider.third_party": "第三方連接",
|
||||||
"hour_interval_one": "{{count}} 小時",
|
"hour_interval_one": "{{count}} 小時",
|
||||||
"hour_interval_other": "{{count}} 小時",
|
"hour_interval_other": "{{count}} 小時",
|
||||||
|
"local": {
|
||||||
|
"title": "本地備份",
|
||||||
|
"directory": "備份目錄",
|
||||||
|
"directory.placeholder": "請選擇備份目錄",
|
||||||
|
"backup.button": "本地備份",
|
||||||
|
"backup.modal.title": "本地備份",
|
||||||
|
"backup.modal.filename.placeholder": "請輸入備份文件名",
|
||||||
|
"restore.button": "備份文件管理",
|
||||||
|
"autoSync": "自動備份",
|
||||||
|
"autoSync.off": "關閉",
|
||||||
|
"lastSync": "上次備份",
|
||||||
|
"noSync": "等待下次備份",
|
||||||
|
"syncError": "備份錯誤",
|
||||||
|
"syncStatus": "備份狀態",
|
||||||
|
"minute_interval_one": "{{count}} 分鐘",
|
||||||
|
"minute_interval_other": "{{count}} 分鐘",
|
||||||
|
"hour_interval_one": "{{count}} 小時",
|
||||||
|
"hour_interval_other": "{{count}} 小時",
|
||||||
|
"maxBackups": "最大備份數",
|
||||||
|
"maxBackups.unlimited": "無限制",
|
||||||
|
"backup.manager.title": "備份文件管理",
|
||||||
|
"backup.manager.refresh": "刷新",
|
||||||
|
"backup.manager.delete.selected": "刪除選中",
|
||||||
|
"backup.manager.delete.text": "刪除",
|
||||||
|
"backup.manager.restore.text": "恢復",
|
||||||
|
"backup.manager.restore.success": "恢復成功,應用將很快刷新",
|
||||||
|
"backup.manager.restore.error": "恢復失敗",
|
||||||
|
"backup.manager.delete.confirm.title": "確認刪除",
|
||||||
|
"backup.manager.delete.confirm.single": "確定要刪除備份文件 \"{{fileName}}\" 嗎?此操作無法撤銷。",
|
||||||
|
"backup.manager.delete.confirm.multiple": "確定要刪除選中的 {{count}} 個備份文件嗎?此操作無法撤銷。",
|
||||||
|
"backup.manager.delete.success.single": "刪除成功",
|
||||||
|
"backup.manager.delete.success.multiple": "已刪除 {{count}} 個備份文件",
|
||||||
|
"backup.manager.delete.error": "刪除失敗",
|
||||||
|
"backup.manager.fetch.error": "獲取備份文件失敗",
|
||||||
|
"backup.manager.select.files.delete": "請選擇要刪除的備份文件",
|
||||||
|
"backup.manager.columns.fileName": "文件名",
|
||||||
|
"backup.manager.columns.modifiedTime": "修改時間",
|
||||||
|
"backup.manager.columns.size": "大小",
|
||||||
|
"backup.manager.columns.actions": "操作",
|
||||||
|
"directory.select_error_app_data_path": "新路徑不能與應用數據路徑相同",
|
||||||
|
"directory.select_error_in_app_install_path": "新路徑不能與應用安裝路徑相同",
|
||||||
|
"directory.select_error_write_permission": "新路徑沒有寫入權限",
|
||||||
|
"directory.select_title": "選擇備份目錄"
|
||||||
|
},
|
||||||
"export_menu": {
|
"export_menu": {
|
||||||
"title": "匯出選單設定",
|
"title": "匯出選單設定",
|
||||||
"image": "匯出為圖片",
|
"image": "匯出為圖片",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import KeyvStorage from '@kangfenmao/keyv-storage'
|
import KeyvStorage from '@kangfenmao/keyv-storage'
|
||||||
|
|
||||||
import { startAutoSync } from './services/BackupService'
|
import { startAutoSync, startLocalBackupAutoSync } from './services/BackupService'
|
||||||
import { startNutstoreAutoSync } from './services/NutstoreService'
|
import { startNutstoreAutoSync } from './services/NutstoreService'
|
||||||
import storeSyncService from './services/StoreSyncService'
|
import storeSyncService from './services/StoreSyncService'
|
||||||
import store from './store'
|
import store from './store'
|
||||||
@ -12,7 +12,7 @@ function initKeyv() {
|
|||||||
|
|
||||||
function initAutoSync() {
|
function initAutoSync() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const { webdavAutoSync, s3 } = store.getState().settings
|
const { webdavAutoSync, localBackupAutoSync, s3 } = store.getState().settings
|
||||||
const { nutstoreAutoSync } = store.getState().nutstore
|
const { nutstoreAutoSync } = store.getState().nutstore
|
||||||
if (webdavAutoSync || (s3 && s3.autoSync)) {
|
if (webdavAutoSync || (s3 && s3.autoSync)) {
|
||||||
startAutoSync()
|
startAutoSync()
|
||||||
@ -20,6 +20,9 @@ function initAutoSync() {
|
|||||||
if (nutstoreAutoSync) {
|
if (nutstoreAutoSync) {
|
||||||
startNutstoreAutoSync()
|
startNutstoreAutoSync()
|
||||||
}
|
}
|
||||||
|
if (localBackupAutoSync) {
|
||||||
|
startLocalBackupAutoSync()
|
||||||
|
}
|
||||||
}, 8000)
|
}, 8000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,7 @@ import {
|
|||||||
import AgentsSubscribeUrlSettings from './AgentsSubscribeUrlSettings'
|
import AgentsSubscribeUrlSettings from './AgentsSubscribeUrlSettings'
|
||||||
import ExportMenuOptions from './ExportMenuSettings'
|
import ExportMenuOptions from './ExportMenuSettings'
|
||||||
import JoplinSettings from './JoplinSettings'
|
import JoplinSettings from './JoplinSettings'
|
||||||
|
import LocalBackupSettings from './LocalBackupSettings'
|
||||||
import MarkdownExportSettings from './MarkdownExportSettings'
|
import MarkdownExportSettings from './MarkdownExportSettings'
|
||||||
import NotionSettings from './NotionSettings'
|
import NotionSettings from './NotionSettings'
|
||||||
import NutstoreSettings from './NutstoreSettings'
|
import NutstoreSettings from './NutstoreSettings'
|
||||||
@ -88,6 +89,7 @@ const DataSettings: FC = () => {
|
|||||||
{ key: 'divider_0', isDivider: true, text: t('settings.data.divider.basic') },
|
{ key: 'divider_0', isDivider: true, text: t('settings.data.divider.basic') },
|
||||||
{ key: 'data', title: 'settings.data.data.title', icon: <FolderCog size={16} /> },
|
{ key: 'data', title: 'settings.data.data.title', icon: <FolderCog size={16} /> },
|
||||||
{ key: 'divider_1', isDivider: true, text: t('settings.data.divider.cloud_storage') },
|
{ key: 'divider_1', isDivider: true, text: t('settings.data.divider.cloud_storage') },
|
||||||
|
{ key: 'local_backup', title: 'settings.data.local.title', icon: <FolderCog size={16} /> },
|
||||||
{ key: 'webdav', title: 'settings.data.webdav.title', icon: <CloudSyncOutlined style={{ fontSize: 16 }} /> },
|
{ key: 'webdav', title: 'settings.data.webdav.title', icon: <CloudSyncOutlined style={{ fontSize: 16 }} /> },
|
||||||
{ key: 'nutstore', title: 'settings.data.nutstore.title', icon: <NutstoreIcon /> },
|
{ key: 'nutstore', title: 'settings.data.nutstore.title', icon: <NutstoreIcon /> },
|
||||||
{ key: 's3', title: 'settings.data.s3.title', icon: <CloudServerOutlined style={{ fontSize: 16 }} /> },
|
{ key: 's3', title: 'settings.data.s3.title', icon: <CloudServerOutlined style={{ fontSize: 16 }} /> },
|
||||||
@ -665,6 +667,7 @@ const DataSettings: FC = () => {
|
|||||||
{menu === 'obsidian' && <ObsidianSettings />}
|
{menu === 'obsidian' && <ObsidianSettings />}
|
||||||
{menu === 'siyuan' && <SiyuanSettings />}
|
{menu === 'siyuan' && <SiyuanSettings />}
|
||||||
{menu === 'agentssubscribe_url' && <AgentsSubscribeUrlSettings />}
|
{menu === 'agentssubscribe_url' && <AgentsSubscribeUrlSettings />}
|
||||||
|
{menu === 'local_backup' && <LocalBackupSettings />}
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1,279 @@
|
|||||||
|
import { DeleteOutlined, FolderOpenOutlined, SaveOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons'
|
||||||
|
import { HStack } from '@renderer/components/Layout'
|
||||||
|
import { LocalBackupManager } from '@renderer/components/LocalBackupManager'
|
||||||
|
import { LocalBackupModal, useLocalBackupModal } from '@renderer/components/LocalBackupModals'
|
||||||
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
|
import { startLocalBackupAutoSync, stopLocalBackupAutoSync } from '@renderer/services/BackupService'
|
||||||
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
|
import {
|
||||||
|
setLocalBackupAutoSync,
|
||||||
|
setLocalBackupDir as _setLocalBackupDir,
|
||||||
|
setLocalBackupMaxBackups as _setLocalBackupMaxBackups,
|
||||||
|
setLocalBackupSkipBackupFile as _setLocalBackupSkipBackupFile,
|
||||||
|
setLocalBackupSyncInterval as _setLocalBackupSyncInterval
|
||||||
|
} from '@renderer/store/settings'
|
||||||
|
import { AppInfo } from '@renderer/types'
|
||||||
|
import { Button, Input, Select, Switch, Tooltip } from 'antd'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { FC, useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||||
|
|
||||||
|
const LocalBackupSettings: FC = () => {
|
||||||
|
const {
|
||||||
|
localBackupDir: localBackupDirSetting,
|
||||||
|
localBackupSyncInterval: localBackupSyncIntervalSetting,
|
||||||
|
localBackupMaxBackups: localBackupMaxBackupsSetting,
|
||||||
|
localBackupSkipBackupFile: localBackupSkipBackupFileSetting
|
||||||
|
} = useSettings()
|
||||||
|
|
||||||
|
const [localBackupDir, setLocalBackupDir] = useState<string | undefined>(localBackupDirSetting)
|
||||||
|
const [localBackupSkipBackupFile, setLocalBackupSkipBackupFile] = useState<boolean>(localBackupSkipBackupFileSetting)
|
||||||
|
const [backupManagerVisible, setBackupManagerVisible] = useState(false)
|
||||||
|
|
||||||
|
const [syncInterval, setSyncInterval] = useState<number>(localBackupSyncIntervalSetting)
|
||||||
|
const [maxBackups, setMaxBackups] = useState<number>(localBackupMaxBackupsSetting)
|
||||||
|
|
||||||
|
const [appInfo, setAppInfo] = useState<AppInfo>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.api.getAppInfo().then(setAppInfo)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { localBackupSync } = useAppSelector((state) => state.backup)
|
||||||
|
|
||||||
|
const onSyncIntervalChange = (value: number) => {
|
||||||
|
setSyncInterval(value)
|
||||||
|
dispatch(_setLocalBackupSyncInterval(value))
|
||||||
|
if (value === 0) {
|
||||||
|
dispatch(setLocalBackupAutoSync(false))
|
||||||
|
stopLocalBackupAutoSync()
|
||||||
|
} else {
|
||||||
|
dispatch(setLocalBackupAutoSync(true))
|
||||||
|
startLocalBackupAutoSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkLocalBackupDirValid = async (dir: string) => {
|
||||||
|
if (dir === '') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// check new local backup dir is not in app data path
|
||||||
|
// if is in app data path, show error
|
||||||
|
if (dir.startsWith(appInfo!.appDataPath)) {
|
||||||
|
window.message.error(t('settings.data.local.directory.select_error_app_data_path'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// check new local backup dir is not in app install path
|
||||||
|
// if is in app install path, show error
|
||||||
|
if (dir.startsWith(appInfo!.installPath)) {
|
||||||
|
window.message.error(t('settings.data.local.directory.select_error_in_app_install_path'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// check new app data path has write permission
|
||||||
|
const hasWritePermission = await window.api.hasWritePermission(dir)
|
||||||
|
if (!hasWritePermission) {
|
||||||
|
window.message.error(t('settings.data.local.directory.select_error_write_permission'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLocalBackupDirChange = async (value: string) => {
|
||||||
|
if (await checkLocalBackupDirValid(value)) {
|
||||||
|
setLocalBackupDir(value)
|
||||||
|
dispatch(_setLocalBackupDir(value))
|
||||||
|
// Create directory if it doesn't exist and set it in the backend
|
||||||
|
await window.api.backup.setLocalBackupDir(value)
|
||||||
|
|
||||||
|
dispatch(setLocalBackupAutoSync(true))
|
||||||
|
startLocalBackupAutoSync(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalBackupDir('')
|
||||||
|
dispatch(_setLocalBackupDir(''))
|
||||||
|
dispatch(setLocalBackupAutoSync(false))
|
||||||
|
stopLocalBackupAutoSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMaxBackupsChange = (value: number) => {
|
||||||
|
setMaxBackups(value)
|
||||||
|
dispatch(_setLocalBackupMaxBackups(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSkipBackupFilesChange = (value: boolean) => {
|
||||||
|
setLocalBackupSkipBackupFile(value)
|
||||||
|
dispatch(_setLocalBackupSkipBackupFile(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBrowseDirectory = async () => {
|
||||||
|
try {
|
||||||
|
const newLocalBackupDir = await window.api.select({
|
||||||
|
properties: ['openDirectory', 'createDirectory'],
|
||||||
|
title: t('settings.data.local.directory.select_title')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!newLocalBackupDir) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLocalBackupDirChange(newLocalBackupDir)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to select directory:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearDirectory = () => {
|
||||||
|
setLocalBackupDir('')
|
||||||
|
dispatch(_setLocalBackupDir(''))
|
||||||
|
dispatch(setLocalBackupAutoSync(false))
|
||||||
|
stopLocalBackupAutoSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSyncStatus = () => {
|
||||||
|
if (!localBackupDir) return null
|
||||||
|
|
||||||
|
if (!localBackupSync.lastSyncTime && !localBackupSync.syncing && !localBackupSync.lastSyncError) {
|
||||||
|
return <span style={{ color: 'var(--text-secondary)' }}>{t('settings.data.local.noSync')}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack gap="5px" alignItems="center">
|
||||||
|
{localBackupSync.syncing && <SyncOutlined spin />}
|
||||||
|
{!localBackupSync.syncing && localBackupSync.lastSyncError && (
|
||||||
|
<Tooltip title={`${t('settings.data.local.syncError')}: ${localBackupSync.lastSyncError}`}>
|
||||||
|
<WarningOutlined style={{ color: 'red' }} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{localBackupSync.lastSyncTime && (
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{t('settings.data.local.lastSync')}: {dayjs(localBackupSync.lastSyncTime).format('HH:mm:ss')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isModalVisible, handleBackup, handleCancel, backuping, customFileName, setCustomFileName, showBackupModal } =
|
||||||
|
useLocalBackupModal(localBackupDir)
|
||||||
|
|
||||||
|
const showBackupManager = () => {
|
||||||
|
setBackupManagerVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeBackupManager = () => {
|
||||||
|
setBackupManagerVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingGroup theme={theme}>
|
||||||
|
<SettingTitle>{t('settings.data.local.title')}</SettingTitle>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.data.local.directory')}</SettingRowTitle>
|
||||||
|
<HStack gap="5px">
|
||||||
|
<Input
|
||||||
|
value={localBackupDir}
|
||||||
|
readOnly
|
||||||
|
style={{ width: 250 }}
|
||||||
|
placeholder={t('settings.data.local.directory.placeholder')}
|
||||||
|
/>
|
||||||
|
<Button icon={<FolderOpenOutlined />} onClick={handleBrowseDirectory}>
|
||||||
|
{t('common.browse')}
|
||||||
|
</Button>
|
||||||
|
<Button icon={<DeleteOutlined />} onClick={handleClearDirectory} disabled={!localBackupDir} danger>
|
||||||
|
{t('common.clear')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
|
||||||
|
<HStack gap="5px" justifyContent="space-between">
|
||||||
|
<Button onClick={showBackupModal} icon={<SaveOutlined />} loading={backuping} disabled={!localBackupDir}>
|
||||||
|
{t('settings.data.local.backup.button')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={showBackupManager} icon={<FolderOpenOutlined />} disabled={!localBackupDir}>
|
||||||
|
{t('settings.data.local.restore.button')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.data.local.autoSync')}</SettingRowTitle>
|
||||||
|
<Select value={syncInterval} onChange={onSyncIntervalChange} disabled={!localBackupDir} style={{ width: 120 }}>
|
||||||
|
<Select.Option value={0}>{t('settings.data.local.autoSync.off')}</Select.Option>
|
||||||
|
<Select.Option value={1}>{t('settings.data.local.minute_interval', { count: 1 })}</Select.Option>
|
||||||
|
<Select.Option value={5}>{t('settings.data.local.minute_interval', { count: 5 })}</Select.Option>
|
||||||
|
<Select.Option value={15}>{t('settings.data.local.minute_interval', { count: 15 })}</Select.Option>
|
||||||
|
<Select.Option value={30}>{t('settings.data.local.minute_interval', { count: 30 })}</Select.Option>
|
||||||
|
<Select.Option value={60}>{t('settings.data.local.hour_interval', { count: 1 })}</Select.Option>
|
||||||
|
<Select.Option value={120}>{t('settings.data.local.hour_interval', { count: 2 })}</Select.Option>
|
||||||
|
<Select.Option value={360}>{t('settings.data.local.hour_interval', { count: 6 })}</Select.Option>
|
||||||
|
<Select.Option value={720}>{t('settings.data.local.hour_interval', { count: 12 })}</Select.Option>
|
||||||
|
<Select.Option value={1440}>{t('settings.data.local.hour_interval', { count: 24 })}</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.data.local.maxBackups')}</SettingRowTitle>
|
||||||
|
<Select value={maxBackups} onChange={onMaxBackupsChange} disabled={!localBackupDir} style={{ width: 120 }}>
|
||||||
|
<Select.Option value={0}>{t('settings.data.local.maxBackups.unlimited')}</Select.Option>
|
||||||
|
<Select.Option value={1}>1</Select.Option>
|
||||||
|
<Select.Option value={3}>3</Select.Option>
|
||||||
|
<Select.Option value={5}>5</Select.Option>
|
||||||
|
<Select.Option value={10}>10</Select.Option>
|
||||||
|
<Select.Option value={20}>20</Select.Option>
|
||||||
|
<Select.Option value={50}>50</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.data.backup.skip_file_data_title')}</SettingRowTitle>
|
||||||
|
<Switch checked={localBackupSkipBackupFile} onChange={onSkipBackupFilesChange} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow>
|
||||||
|
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
||||||
|
</SettingRow>
|
||||||
|
{localBackupSync && syncInterval > 0 && (
|
||||||
|
<>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.data.local.syncStatus')}</SettingRowTitle>
|
||||||
|
{renderSyncStatus()}
|
||||||
|
</SettingRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<>
|
||||||
|
<LocalBackupModal
|
||||||
|
isModalVisible={isModalVisible}
|
||||||
|
handleBackup={handleBackup}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
backuping={backuping}
|
||||||
|
customFileName={customFileName}
|
||||||
|
setCustomFileName={setCustomFileName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LocalBackupManager
|
||||||
|
visible={backupManagerVisible}
|
||||||
|
onClose={closeBackupManager}
|
||||||
|
localBackupDir={localBackupDir}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
</SettingGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LocalBackupSettings
|
||||||
@ -3,8 +3,7 @@ import db from '@renderer/databases'
|
|||||||
import { upgradeToV7, upgradeToV8 } from '@renderer/databases/upgrades'
|
import { upgradeToV7, upgradeToV8 } from '@renderer/databases/upgrades'
|
||||||
import i18n from '@renderer/i18n'
|
import i18n from '@renderer/i18n'
|
||||||
import store from '@renderer/store'
|
import store from '@renderer/store'
|
||||||
import { setWebDAVSyncState } from '@renderer/store/backup'
|
import { setLocalBackupSyncState, setS3SyncState, setWebDAVSyncState } from '@renderer/store/backup'
|
||||||
import { setS3SyncState } from '@renderer/store/backup'
|
|
||||||
import { S3Config, WebDavConfig } from '@renderer/types'
|
import { S3Config, WebDavConfig } from '@renderer/types'
|
||||||
import { uuid } from '@renderer/utils'
|
import { uuid } from '@renderer/utils'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@ -469,11 +468,15 @@ export function startAutoSync(immediate = false) {
|
|||||||
const s3AutoSync = s3Settings?.autoSync
|
const s3AutoSync = s3Settings?.autoSync
|
||||||
const s3Endpoint = s3Settings?.endpoint
|
const s3Endpoint = s3Settings?.endpoint
|
||||||
|
|
||||||
|
const localBackupAutoSync = settings.localBackupAutoSync
|
||||||
|
const localBackupDir = settings.localBackupDir
|
||||||
|
|
||||||
// 检查WebDAV或S3自动同步配置
|
// 检查WebDAV或S3自动同步配置
|
||||||
const hasWebdavConfig = webdavAutoSync && webdavHost
|
const hasWebdavConfig = webdavAutoSync && webdavHost
|
||||||
const hasS3Config = s3AutoSync && s3Endpoint
|
const hasS3Config = s3AutoSync && s3Endpoint
|
||||||
|
const hasLocalConfig = localBackupAutoSync && localBackupDir
|
||||||
|
|
||||||
if (!hasWebdavConfig && !hasS3Config) {
|
if (!hasWebdavConfig && !hasS3Config && !hasLocalConfig) {
|
||||||
Logger.log('[AutoSync] Invalid sync settings, auto sync disabled')
|
Logger.log('[AutoSync] Invalid sync settings, auto sync disabled')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -717,3 +720,279 @@ async function clearDatabase() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup to local directory
|
||||||
|
*/
|
||||||
|
export async function backupToLocalDir({
|
||||||
|
showMessage = false,
|
||||||
|
customFileName = '',
|
||||||
|
autoBackupProcess = false
|
||||||
|
}: { showMessage?: boolean; customFileName?: string; autoBackupProcess?: boolean } = {}) {
|
||||||
|
const notificationService = NotificationService.getInstance()
|
||||||
|
if (isManualBackupRunning) {
|
||||||
|
Logger.log('[Backup] Manual backup already in progress')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// force set showMessage to false when auto backup process
|
||||||
|
if (autoBackupProcess) {
|
||||||
|
showMessage = false
|
||||||
|
}
|
||||||
|
|
||||||
|
isManualBackupRunning = true
|
||||||
|
|
||||||
|
store.dispatch(setLocalBackupSyncState({ syncing: true, lastSyncError: null }))
|
||||||
|
|
||||||
|
const { localBackupDir, localBackupMaxBackups, localBackupSkipBackupFile } = store.getState().settings
|
||||||
|
let deviceType = 'unknown'
|
||||||
|
let hostname = 'unknown'
|
||||||
|
try {
|
||||||
|
deviceType = (await window.api.system.getDeviceType()) || 'unknown'
|
||||||
|
hostname = (await window.api.system.getHostname()) || 'unknown'
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[Backup] Failed to get device type or hostname:', error)
|
||||||
|
}
|
||||||
|
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||||
|
const backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
|
||||||
|
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
|
||||||
|
const backupData = await getBackupData()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.api.backup.backupToLocalDir(backupData, finalFileName, {
|
||||||
|
localBackupDir,
|
||||||
|
skipBackupFile: localBackupSkipBackupFile
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
store.dispatch(
|
||||||
|
setLocalBackupSyncState({
|
||||||
|
lastSyncError: null
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showMessage) {
|
||||||
|
notificationService.send({
|
||||||
|
id: uuid(),
|
||||||
|
type: 'success',
|
||||||
|
title: i18n.t('common.success'),
|
||||||
|
message: i18n.t('message.backup.success'),
|
||||||
|
silent: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
source: 'backup'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up old backups if maxBackups is set
|
||||||
|
if (localBackupMaxBackups > 0) {
|
||||||
|
try {
|
||||||
|
// Get all backup files
|
||||||
|
const files = await window.api.backup.listLocalBackupFiles(localBackupDir)
|
||||||
|
|
||||||
|
// Filter backups for current device
|
||||||
|
const currentDeviceFiles = files.filter((file) => {
|
||||||
|
return file.fileName.includes(deviceType) && file.fileName.includes(hostname)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (currentDeviceFiles.length > localBackupMaxBackups) {
|
||||||
|
// Sort by modified time (oldest first)
|
||||||
|
const filesToDelete = currentDeviceFiles
|
||||||
|
.sort((a, b) => new Date(a.modifiedTime).getTime() - new Date(b.modifiedTime).getTime())
|
||||||
|
.slice(0, currentDeviceFiles.length - localBackupMaxBackups)
|
||||||
|
|
||||||
|
// Delete older backups
|
||||||
|
for (const file of filesToDelete) {
|
||||||
|
Logger.log(`[LocalBackup] Deleting old backup: ${file.fileName}`)
|
||||||
|
await window.api.backup.deleteLocalBackupFile(file.fileName, localBackupDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[LocalBackup] Failed to clean up old backups:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error: any) {
|
||||||
|
Logger.error('[LocalBackup] Backup failed:', error)
|
||||||
|
|
||||||
|
store.dispatch(
|
||||||
|
setLocalBackupSyncState({
|
||||||
|
lastSyncError: error.message || 'Unknown error'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showMessage) {
|
||||||
|
window.modal.error({
|
||||||
|
title: i18n.t('message.backup.failed'),
|
||||||
|
content: error.message || 'Unknown error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
if (!autoBackupProcess) {
|
||||||
|
store.dispatch(
|
||||||
|
setLocalBackupSyncState({
|
||||||
|
lastSyncTime: Date.now(),
|
||||||
|
syncing: false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isManualBackupRunning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreFromLocalBackup(fileName: string) {
|
||||||
|
try {
|
||||||
|
const { localBackupDir } = store.getState().settings
|
||||||
|
await window.api.backup.restoreFromLocalBackup(fileName, localBackupDir)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[LocalBackup] Restore failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local backup auto sync
|
||||||
|
let localBackupAutoSyncStarted = false
|
||||||
|
let localBackupSyncTimeout: NodeJS.Timeout | null = null
|
||||||
|
let isLocalBackupAutoRunning = false
|
||||||
|
|
||||||
|
export function startLocalBackupAutoSync(immediate = false) {
|
||||||
|
if (localBackupAutoSyncStarted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { localBackupAutoSync, localBackupDir } = store.getState().settings
|
||||||
|
|
||||||
|
if (!localBackupAutoSync || !localBackupDir) {
|
||||||
|
Logger.log('[LocalBackupAutoSync] Invalid sync settings, auto sync disabled')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localBackupAutoSyncStarted = true
|
||||||
|
|
||||||
|
stopLocalBackupAutoSync()
|
||||||
|
|
||||||
|
scheduleNextBackup(immediate ? 'immediate' : 'fromLastSyncTime')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param type 'immediate' | 'fromLastSyncTime' | 'fromNow'
|
||||||
|
* 'immediate', first backup right now
|
||||||
|
* 'fromLastSyncTime', schedule next backup from last sync time
|
||||||
|
* 'fromNow', schedule next backup from now
|
||||||
|
*/
|
||||||
|
function scheduleNextBackup(type: 'immediate' | 'fromLastSyncTime' | 'fromNow' = 'fromLastSyncTime') {
|
||||||
|
if (localBackupSyncTimeout) {
|
||||||
|
clearTimeout(localBackupSyncTimeout)
|
||||||
|
localBackupSyncTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { localBackupSyncInterval } = store.getState().settings
|
||||||
|
const { localBackupSync } = store.getState().backup
|
||||||
|
|
||||||
|
if (localBackupSyncInterval <= 0) {
|
||||||
|
Logger.log('[LocalBackupAutoSync] Invalid sync interval, auto sync disabled')
|
||||||
|
stopLocalBackupAutoSync()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// User specified auto backup interval (milliseconds)
|
||||||
|
const requiredInterval = localBackupSyncInterval * 60 * 1000
|
||||||
|
|
||||||
|
let timeUntilNextSync = 1000 // immediate by default
|
||||||
|
switch (type) {
|
||||||
|
case 'fromLastSyncTime': // If last sync time exists, use it as reference
|
||||||
|
timeUntilNextSync = Math.max(1000, (localBackupSync?.lastSyncTime || 0) + requiredInterval - Date.now())
|
||||||
|
break
|
||||||
|
case 'fromNow':
|
||||||
|
timeUntilNextSync = requiredInterval
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
localBackupSyncTimeout = setTimeout(performAutoBackup, timeUntilNextSync)
|
||||||
|
|
||||||
|
Logger.log(
|
||||||
|
`[LocalBackupAutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor(
|
||||||
|
(timeUntilNextSync / 1000) % 60
|
||||||
|
)} seconds`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performAutoBackup() {
|
||||||
|
if (isLocalBackupAutoRunning || isManualBackupRunning) {
|
||||||
|
Logger.log('[LocalBackupAutoSync] Backup already in progress, rescheduling')
|
||||||
|
scheduleNextBackup()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLocalBackupAutoRunning = true
|
||||||
|
const maxRetries = 4
|
||||||
|
let retryCount = 0
|
||||||
|
|
||||||
|
while (retryCount < maxRetries) {
|
||||||
|
try {
|
||||||
|
Logger.log(`[LocalBackupAutoSync] Starting auto backup... (attempt ${retryCount + 1}/${maxRetries})`)
|
||||||
|
|
||||||
|
await backupToLocalDir({ autoBackupProcess: true })
|
||||||
|
|
||||||
|
store.dispatch(
|
||||||
|
setLocalBackupSyncState({
|
||||||
|
lastSyncError: null,
|
||||||
|
lastSyncTime: Date.now(),
|
||||||
|
syncing: false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
isLocalBackupAutoRunning = false
|
||||||
|
scheduleNextBackup()
|
||||||
|
|
||||||
|
break
|
||||||
|
} catch (error: any) {
|
||||||
|
retryCount++
|
||||||
|
if (retryCount === maxRetries) {
|
||||||
|
Logger.error('[LocalBackupAutoSync] Auto backup failed after all retries:', error)
|
||||||
|
|
||||||
|
store.dispatch(
|
||||||
|
setLocalBackupSyncState({
|
||||||
|
lastSyncError: 'Auto backup failed',
|
||||||
|
lastSyncTime: Date.now(),
|
||||||
|
syncing: false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only show error modal once and wait for user acknowledgment
|
||||||
|
await window.modal.error({
|
||||||
|
title: i18n.t('message.backup.failed'),
|
||||||
|
content: `[Local Backup Auto Backup] ${new Date().toLocaleString()} ` + error.message
|
||||||
|
})
|
||||||
|
|
||||||
|
scheduleNextBackup('fromNow')
|
||||||
|
isLocalBackupAutoRunning = false
|
||||||
|
} else {
|
||||||
|
// Exponential Backoff with Base 2: 7s, 17s, 37s
|
||||||
|
const backoffDelay = Math.pow(2, retryCount - 1) * 10000 - 3000
|
||||||
|
Logger.log(`[LocalBackupAutoSync] Failed, retry ${retryCount}/${maxRetries} after ${backoffDelay / 1000}s`)
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, backoffDelay))
|
||||||
|
|
||||||
|
// Check if auto backup was stopped by user
|
||||||
|
if (!isLocalBackupAutoRunning) {
|
||||||
|
Logger.log('[LocalBackupAutoSync] retry cancelled by user, exit')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopLocalBackupAutoSync() {
|
||||||
|
if (localBackupSyncTimeout) {
|
||||||
|
Logger.log('[LocalBackupAutoSync] Stopping auto sync')
|
||||||
|
clearTimeout(localBackupSyncTimeout)
|
||||||
|
localBackupSyncTimeout = null
|
||||||
|
}
|
||||||
|
isLocalBackupAutoRunning = false
|
||||||
|
localBackupAutoSyncStarted = false
|
||||||
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export interface RemoteSyncState {
|
|||||||
|
|
||||||
export interface BackupState {
|
export interface BackupState {
|
||||||
webdavSync: RemoteSyncState
|
webdavSync: RemoteSyncState
|
||||||
|
localBackupSync: RemoteSyncState
|
||||||
s3Sync: RemoteSyncState
|
s3Sync: RemoteSyncState
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -17,6 +18,11 @@ const initialState: BackupState = {
|
|||||||
syncing: false,
|
syncing: false,
|
||||||
lastSyncError: null
|
lastSyncError: null
|
||||||
},
|
},
|
||||||
|
localBackupSync: {
|
||||||
|
lastSyncTime: null,
|
||||||
|
syncing: false,
|
||||||
|
lastSyncError: null
|
||||||
|
},
|
||||||
s3Sync: {
|
s3Sync: {
|
||||||
lastSyncTime: null,
|
lastSyncTime: null,
|
||||||
syncing: false,
|
syncing: false,
|
||||||
@ -31,11 +37,14 @@ const backupSlice = createSlice({
|
|||||||
setWebDAVSyncState: (state, action: PayloadAction<Partial<RemoteSyncState>>) => {
|
setWebDAVSyncState: (state, action: PayloadAction<Partial<RemoteSyncState>>) => {
|
||||||
state.webdavSync = { ...state.webdavSync, ...action.payload }
|
state.webdavSync = { ...state.webdavSync, ...action.payload }
|
||||||
},
|
},
|
||||||
|
setLocalBackupSyncState: (state, action: PayloadAction<Partial<RemoteSyncState>>) => {
|
||||||
|
state.localBackupSync = { ...state.localBackupSync, ...action.payload }
|
||||||
|
},
|
||||||
setS3SyncState: (state, action: PayloadAction<Partial<RemoteSyncState>>) => {
|
setS3SyncState: (state, action: PayloadAction<Partial<RemoteSyncState>>) => {
|
||||||
state.s3Sync = { ...state.s3Sync, ...action.payload }
|
state.s3Sync = { ...state.s3Sync, ...action.payload }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { setWebDAVSyncState, setS3SyncState } = backupSlice.actions
|
export const { setWebDAVSyncState, setLocalBackupSyncState, setS3SyncState } = backupSlice.actions
|
||||||
export default backupSlice.reducer
|
export default backupSlice.reducer
|
||||||
|
|||||||
@ -1723,6 +1723,7 @@ const migrateConfig = {
|
|||||||
addProvider(state, 'new-api')
|
addProvider(state, 'new-api')
|
||||||
state.llm.providers = moveProvider(state.llm.providers, 'new-api', 16)
|
state.llm.providers = moveProvider(state.llm.providers, 'new-api', 16)
|
||||||
state.settings.disableHardwareAcceleration = false
|
state.settings.disableHardwareAcceleration = false
|
||||||
|
|
||||||
return state
|
return state
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return state
|
return state
|
||||||
@ -1746,6 +1747,12 @@ const migrateConfig = {
|
|||||||
const newLang = langMap[origin]
|
const newLang = langMap[origin]
|
||||||
if (newLang) state.settings.targetLanguage = newLang
|
if (newLang) state.settings.targetLanguage = newLang
|
||||||
else state.settings.targetLanguage = 'en-us'
|
else state.settings.targetLanguage = 'en-us'
|
||||||
|
|
||||||
|
state.settings.localBackupMaxBackups = 0
|
||||||
|
state.settings.localBackupSkipBackupFile = false
|
||||||
|
state.settings.localBackupDir = ''
|
||||||
|
state.settings.localBackupAutoSync = false
|
||||||
|
state.settings.localBackupSyncInterval = 0
|
||||||
return state
|
return state
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return state
|
return state
|
||||||
|
|||||||
@ -189,6 +189,12 @@ export interface SettingsState {
|
|||||||
backup: boolean
|
backup: boolean
|
||||||
knowledge: boolean
|
knowledge: boolean
|
||||||
}
|
}
|
||||||
|
// Local backup settings
|
||||||
|
localBackupDir: string
|
||||||
|
localBackupAutoSync: boolean
|
||||||
|
localBackupSyncInterval: number
|
||||||
|
localBackupMaxBackups: number
|
||||||
|
localBackupSkipBackupFile: boolean
|
||||||
defaultPaintingProvider: PaintingProvider
|
defaultPaintingProvider: PaintingProvider
|
||||||
s3: S3Config
|
s3: S3Config
|
||||||
}
|
}
|
||||||
@ -338,6 +344,12 @@ export const initialState: SettingsState = {
|
|||||||
backup: false,
|
backup: false,
|
||||||
knowledge: false
|
knowledge: false
|
||||||
},
|
},
|
||||||
|
// Local backup settings
|
||||||
|
localBackupDir: '',
|
||||||
|
localBackupAutoSync: false,
|
||||||
|
localBackupSyncInterval: 0,
|
||||||
|
localBackupMaxBackups: 0,
|
||||||
|
localBackupSkipBackupFile: false,
|
||||||
defaultPaintingProvider: 'aihubmix',
|
defaultPaintingProvider: 'aihubmix',
|
||||||
s3: {
|
s3: {
|
||||||
endpoint: '',
|
endpoint: '',
|
||||||
@ -715,6 +727,22 @@ const settingsSlice = createSlice({
|
|||||||
setNotificationSettings: (state, action: PayloadAction<SettingsState['notification']>) => {
|
setNotificationSettings: (state, action: PayloadAction<SettingsState['notification']>) => {
|
||||||
state.notification = action.payload
|
state.notification = action.payload
|
||||||
},
|
},
|
||||||
|
// Local backup settings
|
||||||
|
setLocalBackupDir: (state, action: PayloadAction<string>) => {
|
||||||
|
state.localBackupDir = action.payload
|
||||||
|
},
|
||||||
|
setLocalBackupAutoSync: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.localBackupAutoSync = action.payload
|
||||||
|
},
|
||||||
|
setLocalBackupSyncInterval: (state, action: PayloadAction<number>) => {
|
||||||
|
state.localBackupSyncInterval = action.payload
|
||||||
|
},
|
||||||
|
setLocalBackupMaxBackups: (state, action: PayloadAction<number>) => {
|
||||||
|
state.localBackupMaxBackups = action.payload
|
||||||
|
},
|
||||||
|
setLocalBackupSkipBackupFile: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.localBackupSkipBackupFile = action.payload
|
||||||
|
},
|
||||||
setDefaultPaintingProvider: (state, action: PayloadAction<PaintingProvider>) => {
|
setDefaultPaintingProvider: (state, action: PayloadAction<PaintingProvider>) => {
|
||||||
state.defaultPaintingProvider = action.payload
|
state.defaultPaintingProvider = action.payload
|
||||||
},
|
},
|
||||||
@ -832,6 +860,12 @@ export const {
|
|||||||
setOpenAISummaryText,
|
setOpenAISummaryText,
|
||||||
setOpenAIServiceTier,
|
setOpenAIServiceTier,
|
||||||
setNotificationSettings,
|
setNotificationSettings,
|
||||||
|
// Local backup settings
|
||||||
|
setLocalBackupDir,
|
||||||
|
setLocalBackupAutoSync,
|
||||||
|
setLocalBackupSyncInterval,
|
||||||
|
setLocalBackupMaxBackups,
|
||||||
|
setLocalBackupSkipBackupFile,
|
||||||
setDefaultPaintingProvider,
|
setDefaultPaintingProvider,
|
||||||
setS3,
|
setS3,
|
||||||
setS3Partial
|
setS3Partial
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user