mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-20 07:00:09 +08:00
feat: object storage backup (#7791)
* chore: import opendal * feat: 添加S3备份支持及相关设置界面 - 在IpcChannel中新增S3备份相关IPC事件,支持备份、恢复、 列表、删除文件及连接检测 - 在ipc主进程注册对应的S3备份处理函数,集成backupManager - 新增S3设置页面,支持配置Endpoint、Region、Bucket、AccessKey等 参数,并提供同步和备份策略的UI控制 - 删除未使用的RemoteStorage.ts,简化代码库 提升备份功能的灵活性,支持S3作为远程存储目标 * feat(S3 Backup): 完善S3备份功能 - 支持自动备份 - 优化设置前端 - 优化备份恢复代码 * feat(i18n): add S3 storage translations * feat(settings): 优化数据设置页面和S3设置页面UI * feat(settings): optimize S3 settings state structure and update usage * refactor: simplify S3 backup and restore modal logic * feat(s3 backup): improve S3 settings defaults and modal props * fix(i18n): optimize S3 access key translations * feat(backup): optimize logging and progress reporting * fix(settings): set S3 maxBackups as unlimited by default * chore(package): restore opendal dependency in package.json * feat(backup): migrate S3 Backup dependency from opendal to aws-sdk * refactor(backup): simplify S3 config handling and partial updates * refactor(backup): update Nutstore sync state to use RemoteSyncState * feat(store): add migration 120 to initialize missing s3 settings * feat(settings): add tooltip and help link for S3 storage * fix(s3settings): disable backup button until all fields are set --------- Co-authored-by: suyao <sy20010504@gmail.com>
This commit is contained in:
parent
1e0f0f47fa
commit
278fd931fb
@ -58,6 +58,7 @@
|
|||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.840.0",
|
||||||
"@cherrystudio/pdf-to-img-napi": "^0.0.1",
|
"@cherrystudio/pdf-to-img-napi": "^0.0.1",
|
||||||
"@libsql/client": "0.14.0",
|
"@libsql/client": "0.14.0",
|
||||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||||
|
|||||||
@ -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_BackupToS3 = 'backup:backupToS3',
|
||||||
|
Backup_RestoreFromS3 = 'backup:restoreFromS3',
|
||||||
|
Backup_ListS3Files = 'backup:listS3Files',
|
||||||
|
Backup_DeleteS3File = 'backup:deleteS3File',
|
||||||
|
Backup_CheckS3Connection = 'backup:checkS3Connection',
|
||||||
|
|
||||||
// zip
|
// zip
|
||||||
Zip_Compress = 'zip:compress',
|
Zip_Compress = 'zip:compress',
|
||||||
|
|||||||
@ -368,6 +368,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_BackupToS3, backupManager.backupToS3)
|
||||||
|
ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3)
|
||||||
|
ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files)
|
||||||
|
ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File)
|
||||||
|
ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection)
|
||||||
|
|
||||||
// file
|
// file
|
||||||
ipcMain.handle(IpcChannel.File_Open, fileManager.open)
|
ipcMain.handle(IpcChannel.File_Open, fileManager.open)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
import { WebDavConfig } from '@types'
|
import { WebDavConfig } from '@types'
|
||||||
|
import { S3Config } from '@types'
|
||||||
import archiver from 'archiver'
|
import archiver from 'archiver'
|
||||||
import { exec } from 'child_process'
|
import { exec } from 'child_process'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
@ -10,6 +11,7 @@ import * as path from 'path'
|
|||||||
import { CreateDirectoryOptions, FileStat } from 'webdav'
|
import { CreateDirectoryOptions, FileStat } from 'webdav'
|
||||||
|
|
||||||
import { getDataPath } from '../utils'
|
import { getDataPath } from '../utils'
|
||||||
|
import S3Storage from './S3Storage'
|
||||||
import WebDav from './WebDav'
|
import WebDav from './WebDav'
|
||||||
import { windowService } from './WindowService'
|
import { windowService } from './WindowService'
|
||||||
|
|
||||||
@ -25,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.backupToS3 = this.backupToS3.bind(this)
|
||||||
|
this.restoreFromS3 = this.restoreFromS3.bind(this)
|
||||||
|
this.listS3Files = this.listS3Files.bind(this)
|
||||||
|
this.deleteS3File = this.deleteS3File.bind(this)
|
||||||
|
this.checkS3Connection = this.checkS3Connection.bind(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setWritableRecursive(dirPath: string): Promise<void> {
|
private async setWritableRecursive(dirPath: string): Promise<void> {
|
||||||
@ -85,7 +92,11 @@ class BackupManager {
|
|||||||
|
|
||||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||||
mainWindow?.webContents.send(IpcChannel.BackupProgress, processData)
|
mainWindow?.webContents.send(IpcChannel.BackupProgress, processData)
|
||||||
Logger.log('[BackupManager] backup progress', processData)
|
// 只在关键阶段记录日志:开始、结束和主要阶段转换点
|
||||||
|
const logStages = ['preparing', 'writing_data', 'preparing_compression', 'completed']
|
||||||
|
if (logStages.includes(processData.stage) || processData.progress === 100) {
|
||||||
|
Logger.log('[BackupManager] backup progress', processData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -147,18 +158,23 @@ class BackupManager {
|
|||||||
let totalBytes = 0
|
let totalBytes = 0
|
||||||
let processedBytes = 0
|
let processedBytes = 0
|
||||||
|
|
||||||
// 首先计算总文件数和总大小
|
// 首先计算总文件数和总大小,但不记录详细日志
|
||||||
const calculateTotals = async (dirPath: string) => {
|
const calculateTotals = async (dirPath: string) => {
|
||||||
const items = await fs.readdir(dirPath, { withFileTypes: true })
|
try {
|
||||||
for (const item of items) {
|
const items = await fs.readdir(dirPath, { withFileTypes: true })
|
||||||
const fullPath = path.join(dirPath, item.name)
|
for (const item of items) {
|
||||||
if (item.isDirectory()) {
|
const fullPath = path.join(dirPath, item.name)
|
||||||
await calculateTotals(fullPath)
|
if (item.isDirectory()) {
|
||||||
} else {
|
await calculateTotals(fullPath)
|
||||||
totalEntries++
|
} else {
|
||||||
const stats = await fs.stat(fullPath)
|
totalEntries++
|
||||||
totalBytes += stats.size
|
const stats = await fs.stat(fullPath)
|
||||||
|
totalBytes += stats.size
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 仅在出错时记录日志
|
||||||
|
Logger.error('[BackupManager] Error calculating totals:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,7 +246,11 @@ class BackupManager {
|
|||||||
|
|
||||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||||
mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData)
|
mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData)
|
||||||
Logger.log('[BackupManager] restore progress', processData)
|
// 只在关键阶段记录日志
|
||||||
|
const logStages = ['preparing', 'extracting', 'extracted', 'reading_data', 'completed']
|
||||||
|
if (logStages.includes(processData.stage) || processData.progress === 100) {
|
||||||
|
Logger.log('[BackupManager] restore progress', processData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -382,21 +402,54 @@ class BackupManager {
|
|||||||
destination: string,
|
destination: string,
|
||||||
onProgress: (size: number) => void
|
onProgress: (size: number) => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const items = await fs.readdir(source, { withFileTypes: true })
|
// 先统计总文件数
|
||||||
|
let totalFiles = 0
|
||||||
|
let processedFiles = 0
|
||||||
|
let lastProgressReported = 0
|
||||||
|
|
||||||
for (const item of items) {
|
// 计算总文件数
|
||||||
const sourcePath = path.join(source, item.name)
|
const countFiles = async (dir: string): Promise<number> => {
|
||||||
const destPath = path.join(destination, item.name)
|
let count = 0
|
||||||
|
const items = await fs.readdir(dir, { withFileTypes: true })
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.isDirectory()) {
|
||||||
|
count += await countFiles(path.join(dir, item.name))
|
||||||
|
} else {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
if (item.isDirectory()) {
|
totalFiles = await countFiles(source)
|
||||||
await fs.ensureDir(destPath)
|
|
||||||
await this.copyDirWithProgress(sourcePath, destPath, onProgress)
|
// 复制文件并更新进度
|
||||||
} else {
|
const copyDir = async (src: string, dest: string): Promise<void> => {
|
||||||
const stats = await fs.stat(sourcePath)
|
const items = await fs.readdir(src, { withFileTypes: true })
|
||||||
await fs.copy(sourcePath, destPath)
|
|
||||||
onProgress(stats.size)
|
for (const item of items) {
|
||||||
|
const sourcePath = path.join(src, item.name)
|
||||||
|
const destPath = path.join(dest, item.name)
|
||||||
|
|
||||||
|
if (item.isDirectory()) {
|
||||||
|
await fs.ensureDir(destPath)
|
||||||
|
await copyDir(sourcePath, destPath)
|
||||||
|
} else {
|
||||||
|
const stats = await fs.stat(sourcePath)
|
||||||
|
await fs.copy(sourcePath, destPath)
|
||||||
|
processedFiles++
|
||||||
|
|
||||||
|
// 只在进度变化超过5%时报告进度
|
||||||
|
const currentProgress = Math.floor((processedFiles / totalFiles) * 100)
|
||||||
|
if (currentProgress - lastProgressReported >= 5 || processedFiles === totalFiles) {
|
||||||
|
lastProgressReported = currentProgress
|
||||||
|
onProgress(stats.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await copyDir(source, destination)
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
|
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
|
||||||
@ -423,6 +476,100 @@ class BackupManager {
|
|||||||
throw new Error(error.message || 'Failed to delete backup file')
|
throw new Error(error.message || 'Failed to delete backup file')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) {
|
||||||
|
const os = require('os')
|
||||||
|
const deviceName = os.hostname ? os.hostname() : 'device'
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[-:T.Z]/g, '')
|
||||||
|
.slice(0, 14)
|
||||||
|
const filename = s3Config.fileName || `cherry-studio.backup.${deviceName}.${timestamp}.zip`
|
||||||
|
|
||||||
|
Logger.log(`[BackupManager] Starting S3 backup to ${filename}`)
|
||||||
|
|
||||||
|
const backupedFilePath = await this.backup(_, filename, data, undefined, s3Config.skipBackupFile)
|
||||||
|
const s3Client = new S3Storage(s3Config)
|
||||||
|
try {
|
||||||
|
const fileBuffer = await fs.promises.readFile(backupedFilePath)
|
||||||
|
const result = await s3Client.putFileContents(filename, fileBuffer)
|
||||||
|
await fs.remove(backupedFilePath)
|
||||||
|
|
||||||
|
Logger.log(`[BackupManager] S3 backup completed successfully: ${filename}`)
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[BackupManager] S3 backup failed:`, error)
|
||||||
|
await fs.remove(backupedFilePath)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreFromS3(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
|
||||||
|
const filename = s3Config.fileName || 'cherry-studio.backup.zip'
|
||||||
|
|
||||||
|
Logger.log(`[BackupManager] Starting restore from S3: ${filename}`)
|
||||||
|
|
||||||
|
const s3Client = new S3Storage(s3Config)
|
||||||
|
try {
|
||||||
|
const retrievedFile = await s3Client.getFileContents(filename)
|
||||||
|
const backupedFilePath = path.join(this.backupDir, filename)
|
||||||
|
if (!fs.existsSync(this.backupDir)) {
|
||||||
|
fs.mkdirSync(this.backupDir, { recursive: true })
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const writeStream = fs.createWriteStream(backupedFilePath)
|
||||||
|
writeStream.write(retrievedFile as Buffer)
|
||||||
|
writeStream.end()
|
||||||
|
writeStream.on('finish', () => resolve())
|
||||||
|
writeStream.on('error', (error) => reject(error))
|
||||||
|
})
|
||||||
|
|
||||||
|
Logger.log(`[BackupManager] S3 restore file downloaded successfully: ${filename}`)
|
||||||
|
return await this.restore(_, backupedFilePath)
|
||||||
|
} catch (error: any) {
|
||||||
|
Logger.error('[BackupManager] Failed to restore from S3:', error)
|
||||||
|
throw new Error(error.message || 'Failed to restore backup file')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listS3Files = async (_: Electron.IpcMainInvokeEvent, s3Config: S3Config) => {
|
||||||
|
try {
|
||||||
|
const s3Client = new S3Storage(s3Config)
|
||||||
|
|
||||||
|
const objects = await s3Client.listFiles()
|
||||||
|
const files = objects
|
||||||
|
.filter((obj) => obj.key.endsWith('.zip'))
|
||||||
|
.map((obj) => {
|
||||||
|
const segments = obj.key.split('/')
|
||||||
|
const fileName = segments[segments.length - 1]
|
||||||
|
return {
|
||||||
|
fileName,
|
||||||
|
modifiedTime: obj.lastModified || '',
|
||||||
|
size: obj.size
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return files.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime())
|
||||||
|
} catch (error: any) {
|
||||||
|
Logger.error('Failed to list S3 files:', error)
|
||||||
|
throw new Error(error.message || 'Failed to list backup files')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteS3File(_: Electron.IpcMainInvokeEvent, fileName: string, s3Config: S3Config) {
|
||||||
|
try {
|
||||||
|
const s3Client = new S3Storage(s3Config)
|
||||||
|
return await s3Client.deleteFile(fileName)
|
||||||
|
} catch (error: any) {
|
||||||
|
Logger.error('Failed to delete S3 file:', error)
|
||||||
|
throw new Error(error.message || 'Failed to delete backup file')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkS3Connection(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
|
||||||
|
const s3Client = new S3Storage(s3Config)
|
||||||
|
return await s3Client.checkConnection()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BackupManager
|
export default BackupManager
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
// import Logger from 'electron-log'
|
|
||||||
// import { Operator } from 'opendal'
|
|
||||||
|
|
||||||
// export default class RemoteStorage {
|
|
||||||
// public instance: Operator | undefined
|
|
||||||
|
|
||||||
// /**
|
|
||||||
// *
|
|
||||||
// * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk"
|
|
||||||
// * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options.
|
|
||||||
// *
|
|
||||||
// * For example, use minio as remote storage:
|
|
||||||
// *
|
|
||||||
// * ```typescript
|
|
||||||
// * const storage = new RemoteStorage('s3', {
|
|
||||||
// * endpoint: 'http://localhost:9000',
|
|
||||||
// * region: 'us-east-1',
|
|
||||||
// * bucket: 'testbucket',
|
|
||||||
// * access_key_id: 'user',
|
|
||||||
// * secret_access_key: 'password',
|
|
||||||
// * root: '/path/to/basepath',
|
|
||||||
// * })
|
|
||||||
// * ```
|
|
||||||
// */
|
|
||||||
// constructor(scheme: string, options?: Record<string, string> | undefined | null) {
|
|
||||||
// this.instance = new Operator(scheme, options)
|
|
||||||
|
|
||||||
// this.putFileContents = this.putFileContents.bind(this)
|
|
||||||
// this.getFileContents = this.getFileContents.bind(this)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// public putFileContents = async (filename: string, data: string | Buffer) => {
|
|
||||||
// if (!this.instance) {
|
|
||||||
// return new Error('RemoteStorage client not initialized')
|
|
||||||
// }
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// return await this.instance.write(filename, data)
|
|
||||||
// } catch (error) {
|
|
||||||
// Logger.error('[RemoteStorage] Error putting file contents:', error)
|
|
||||||
// throw error
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// public getFileContents = async (filename: string) => {
|
|
||||||
// if (!this.instance) {
|
|
||||||
// throw new Error('RemoteStorage client not initialized')
|
|
||||||
// }
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// return await this.instance.read(filename)
|
|
||||||
// } catch (error) {
|
|
||||||
// Logger.error('[RemoteStorage] Error getting file contents:', error)
|
|
||||||
// throw error
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
183
src/main/services/S3Storage.ts
Normal file
183
src/main/services/S3Storage.ts
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import {
|
||||||
|
DeleteObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadBucketCommand,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client
|
||||||
|
} from '@aws-sdk/client-s3'
|
||||||
|
import type { S3Config } from '@types'
|
||||||
|
import Logger from 'electron-log'
|
||||||
|
import * as net from 'net'
|
||||||
|
import { Readable } from 'stream'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将可读流转换为 Buffer
|
||||||
|
*/
|
||||||
|
function streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
|
||||||
|
stream.on('error', reject)
|
||||||
|
stream.on('end', () => resolve(Buffer.concat(chunks)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要使用 Virtual Host-Style 的服务商域名后缀白名单
|
||||||
|
const VIRTUAL_HOST_SUFFIXES = ['aliyuncs.com', 'myqcloud.com']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 AWS SDK v3 的简单 S3 封装,兼容之前 RemoteStorage 的最常用接口。
|
||||||
|
*/
|
||||||
|
export default class S3Storage {
|
||||||
|
private client: S3Client
|
||||||
|
private bucket: string
|
||||||
|
private root: string
|
||||||
|
|
||||||
|
constructor(config: S3Config) {
|
||||||
|
const { endpoint, region, accessKeyId, secretAccessKey, bucket, root } = config
|
||||||
|
|
||||||
|
const usePathStyle = (() => {
|
||||||
|
if (!endpoint) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { hostname } = new URL(endpoint)
|
||||||
|
|
||||||
|
if (hostname === 'localhost' || net.isIP(hostname) !== 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInWhiteList = VIRTUAL_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))
|
||||||
|
return !isInWhiteList
|
||||||
|
} catch (e) {
|
||||||
|
Logger.warn('[S3Storage] Failed to parse endpoint, fallback to Path-Style:', endpoint, e)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
this.client = new S3Client({
|
||||||
|
region,
|
||||||
|
endpoint: endpoint || undefined,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: accessKeyId,
|
||||||
|
secretAccessKey: secretAccessKey
|
||||||
|
},
|
||||||
|
forcePathStyle: usePathStyle
|
||||||
|
})
|
||||||
|
|
||||||
|
this.bucket = bucket
|
||||||
|
this.root = root?.replace(/^\/+/g, '').replace(/\/+$/g, '') || ''
|
||||||
|
|
||||||
|
this.putFileContents = this.putFileContents.bind(this)
|
||||||
|
this.getFileContents = this.getFileContents.bind(this)
|
||||||
|
this.deleteFile = this.deleteFile.bind(this)
|
||||||
|
this.listFiles = this.listFiles.bind(this)
|
||||||
|
this.checkConnection = this.checkConnection.bind(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部辅助方法,用来拼接带 root 的对象 key
|
||||||
|
*/
|
||||||
|
private buildKey(key: string): string {
|
||||||
|
if (!this.root) return key
|
||||||
|
return key.startsWith(`${this.root}/`) ? key : `${this.root}/${key}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async putFileContents(key: string, data: Buffer | string) {
|
||||||
|
try {
|
||||||
|
const contentType = key.endsWith('.zip') ? 'application/zip' : 'application/octet-stream'
|
||||||
|
|
||||||
|
return await this.client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: this.buildKey(key),
|
||||||
|
Body: data,
|
||||||
|
ContentType: contentType
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[S3Storage] Error putting object:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileContents(key: string): Promise<Buffer> {
|
||||||
|
try {
|
||||||
|
const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: this.buildKey(key) }))
|
||||||
|
if (!res.Body || !(res.Body instanceof Readable)) {
|
||||||
|
throw new Error('Empty body received from S3')
|
||||||
|
}
|
||||||
|
return await streamToBuffer(res.Body as Readable)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[S3Storage] Error getting object:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(key: string) {
|
||||||
|
try {
|
||||||
|
const keyWithRoot = this.buildKey(key)
|
||||||
|
const variations = new Set([keyWithRoot, key.replace(/^\//, '')])
|
||||||
|
for (const k of variations) {
|
||||||
|
try {
|
||||||
|
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: k }))
|
||||||
|
} catch {
|
||||||
|
// 忽略删除失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[S3Storage] Error deleting object:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列举指定前缀下的对象,默认列举全部。
|
||||||
|
*/
|
||||||
|
async listFiles(prefix = ''): Promise<Array<{ key: string; lastModified?: string; size: number }>> {
|
||||||
|
const files: Array<{ key: string; lastModified?: string; size: number }> = []
|
||||||
|
let continuationToken: string | undefined
|
||||||
|
const fullPrefix = this.buildKey(prefix)
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
const res = await this.client.send(
|
||||||
|
new ListObjectsV2Command({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Prefix: fullPrefix === '' ? undefined : fullPrefix,
|
||||||
|
ContinuationToken: continuationToken
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
res.Contents?.forEach((obj) => {
|
||||||
|
if (!obj.Key) return
|
||||||
|
files.push({
|
||||||
|
key: obj.Key,
|
||||||
|
lastModified: obj.LastModified?.toISOString(),
|
||||||
|
size: obj.Size ?? 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined
|
||||||
|
} while (continuationToken)
|
||||||
|
|
||||||
|
return files
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[S3Storage] Error listing objects:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试调用 HeadBucket 判断凭证/网络是否可用
|
||||||
|
*/
|
||||||
|
async checkConnection() {
|
||||||
|
try {
|
||||||
|
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }))
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[S3Storage] Error checking connection:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import {
|
|||||||
KnowledgeItem,
|
KnowledgeItem,
|
||||||
MCPServer,
|
MCPServer,
|
||||||
Provider,
|
Provider,
|
||||||
|
S3Config,
|
||||||
Shortcut,
|
Shortcut,
|
||||||
ThemeMode,
|
ThemeMode,
|
||||||
WebDavConfig
|
WebDavConfig
|
||||||
@ -72,9 +73,9 @@ const api = {
|
|||||||
decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text)
|
decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text)
|
||||||
},
|
},
|
||||||
backup: {
|
backup: {
|
||||||
backup: (fileName: string, data: string, destinationPath?: string, skipBackupFile?: boolean) =>
|
backup: (filename: string, content: string, path: string, skipBackupFile: boolean) =>
|
||||||
ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath, skipBackupFile),
|
ipcRenderer.invoke(IpcChannel.Backup_Backup, filename, content, path, skipBackupFile),
|
||||||
restore: (backupPath: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, backupPath),
|
restore: (path: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, path),
|
||||||
backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
|
backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
|
||||||
ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig),
|
ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig),
|
||||||
restoreFromWebdav: (webdavConfig: WebDavConfig) =>
|
restoreFromWebdav: (webdavConfig: WebDavConfig) =>
|
||||||
@ -86,7 +87,16 @@ const api = {
|
|||||||
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
|
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
|
||||||
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),
|
||||||
|
checkWebdavConnection: (webdavConfig: WebDavConfig) =>
|
||||||
|
ipcRenderer.invoke(IpcChannel.Backup_CheckConnection, webdavConfig),
|
||||||
|
|
||||||
|
backupToS3: (data: string, s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_BackupToS3, data, s3Config),
|
||||||
|
restoreFromS3: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_RestoreFromS3, s3Config),
|
||||||
|
listS3Files: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_ListS3Files, s3Config),
|
||||||
|
deleteS3File: (fileName: string, s3Config: S3Config) =>
|
||||||
|
ipcRenderer.invoke(IpcChannel.Backup_DeleteS3File, fileName, s3Config),
|
||||||
|
checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config)
|
||||||
},
|
},
|
||||||
file: {
|
file: {
|
||||||
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
|
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
|
||||||
|
|||||||
295
src/renderer/src/components/S3BackupManager.tsx
Normal file
295
src/renderer/src/components/S3BackupManager.tsx
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||||
|
import { restoreFromS3 } from '@renderer/services/BackupService'
|
||||||
|
import type { S3Config } from '@renderer/types'
|
||||||
|
import { formatFileSize } from '@renderer/utils'
|
||||||
|
import { Button, 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 S3BackupManagerProps {
|
||||||
|
visible: boolean
|
||||||
|
onClose: () => void
|
||||||
|
s3Config: Partial<S3Config>
|
||||||
|
restoreMethod?: (fileName: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S3BackupManagerProps) {
|
||||||
|
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 { t } = useTranslation()
|
||||||
|
|
||||||
|
const { endpoint, region, bucket, accessKeyId, secretAccessKey } = s3Config
|
||||||
|
|
||||||
|
const fetchBackupFiles = useCallback(async () => {
|
||||||
|
if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
|
||||||
|
window.message.error(t('settings.data.s3.manager.config.incomplete'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const files = await window.api.backup.listS3Files({
|
||||||
|
...s3Config,
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
bucket,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
skipBackupFile: false,
|
||||||
|
autoSync: false,
|
||||||
|
syncInterval: 0,
|
||||||
|
maxBackups: 0
|
||||||
|
})
|
||||||
|
setBackupFiles(files)
|
||||||
|
setPagination((prev) => ({
|
||||||
|
...prev,
|
||||||
|
total: files.length
|
||||||
|
}))
|
||||||
|
} catch (error: any) {
|
||||||
|
window.message.error(t('settings.data.s3.manager.files.fetch.error', { message: error.message }))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [endpoint, region, bucket, accessKeyId, secretAccessKey, t, s3Config])
|
||||||
|
|
||||||
|
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) {
|
||||||
|
window.message.warning(t('settings.data.s3.manager.select.warning'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
|
||||||
|
window.message.error(t('settings.data.s3.manager.config.incomplete'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.modal.confirm({
|
||||||
|
title: t('settings.data.s3.manager.delete.confirm.title'),
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
content: t('settings.data.s3.manager.delete.confirm.multiple', { count: selectedRowKeys.length }),
|
||||||
|
okText: t('settings.data.s3.manager.delete.confirm.title'),
|
||||||
|
cancelText: t('common.cancel'),
|
||||||
|
centered: true,
|
||||||
|
onOk: async () => {
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
// 依次删除选中的文件
|
||||||
|
for (const key of selectedRowKeys) {
|
||||||
|
await window.api.backup.deleteS3File(key.toString(), {
|
||||||
|
...s3Config,
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
bucket,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
skipBackupFile: false,
|
||||||
|
autoSync: false,
|
||||||
|
syncInterval: 0,
|
||||||
|
maxBackups: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
window.message.success(
|
||||||
|
t('settings.data.s3.manager.delete.success.multiple', { count: selectedRowKeys.length })
|
||||||
|
)
|
||||||
|
setSelectedRowKeys([])
|
||||||
|
await fetchBackupFiles()
|
||||||
|
} catch (error: any) {
|
||||||
|
window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message }))
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteSingle = async (fileName: string) => {
|
||||||
|
if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
|
||||||
|
window.message.error(t('settings.data.s3.manager.config.incomplete'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.modal.confirm({
|
||||||
|
title: t('settings.data.s3.manager.delete.confirm.title'),
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
content: t('settings.data.s3.manager.delete.confirm.single', { fileName }),
|
||||||
|
okText: t('settings.data.s3.manager.delete.confirm.title'),
|
||||||
|
cancelText: t('common.cancel'),
|
||||||
|
centered: true,
|
||||||
|
onOk: async () => {
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await window.api.backup.deleteS3File(fileName, {
|
||||||
|
...s3Config,
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
bucket,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
skipBackupFile: false,
|
||||||
|
autoSync: false,
|
||||||
|
syncInterval: 0,
|
||||||
|
maxBackups: 0
|
||||||
|
})
|
||||||
|
window.message.success(t('settings.data.s3.manager.delete.success.single'))
|
||||||
|
await fetchBackupFiles()
|
||||||
|
} catch (error: any) {
|
||||||
|
window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message }))
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async (fileName: string) => {
|
||||||
|
if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
|
||||||
|
window.message.error(t('settings.data.s3.manager.config.incomplete'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.modal.confirm({
|
||||||
|
title: t('settings.data.s3.restore.confirm.title'),
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
content: t('settings.data.s3.restore.confirm.content'),
|
||||||
|
okText: t('settings.data.s3.restore.confirm.ok'),
|
||||||
|
cancelText: t('settings.data.s3.restore.confirm.cancel'),
|
||||||
|
centered: true,
|
||||||
|
onOk: async () => {
|
||||||
|
setRestoring(true)
|
||||||
|
try {
|
||||||
|
await (restoreMethod || restoreFromS3)(fileName)
|
||||||
|
window.message.success(t('settings.data.s3.restore.success'))
|
||||||
|
onClose() // 关闭模态框
|
||||||
|
} catch (error: any) {
|
||||||
|
window.message.error(t('settings.data.s3.restore.error', { message: error.message }))
|
||||||
|
} finally {
|
||||||
|
setRestoring(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: t('settings.data.s3.manager.columns.fileName'),
|
||||||
|
dataIndex: 'fileName',
|
||||||
|
key: 'fileName',
|
||||||
|
ellipsis: {
|
||||||
|
showTitle: false
|
||||||
|
},
|
||||||
|
render: (fileName: string) => (
|
||||||
|
<Tooltip placement="topLeft" title={fileName}>
|
||||||
|
{fileName}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('settings.data.s3.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.s3.manager.columns.size'),
|
||||||
|
dataIndex: 'size',
|
||||||
|
key: 'size',
|
||||||
|
width: 120,
|
||||||
|
render: (size: number) => formatFileSize(size)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('settings.data.s3.manager.columns.actions'),
|
||||||
|
key: 'action',
|
||||||
|
width: 160,
|
||||||
|
render: (_: any, record: BackupFile) => (
|
||||||
|
<>
|
||||||
|
<Button type="link" onClick={() => handleRestore(record.fileName)} disabled={restoring || deleting}>
|
||||||
|
{t('settings.data.s3.manager.restore')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
onClick={() => handleDeleteSingle(record.fileName)}
|
||||||
|
disabled={deleting || restoring}>
|
||||||
|
{t('settings.data.s3.manager.delete')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const rowSelection = {
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (selectedRowKeys: React.Key[]) => {
|
||||||
|
setSelectedRowKeys(selectedRowKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('settings.data.s3.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.s3.manager.refresh')}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="delete"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={handleDeleteSelected}
|
||||||
|
disabled={selectedRowKeys.length === 0 || deleting}
|
||||||
|
loading={deleting}>
|
||||||
|
{t('settings.data.s3.manager.delete.selected', { count: selectedRowKeys.length })}
|
||||||
|
</Button>,
|
||||||
|
<Button key="close" onClick={onClose}>
|
||||||
|
{t('settings.data.s3.manager.close')}
|
||||||
|
</Button>
|
||||||
|
]}>
|
||||||
|
<Table
|
||||||
|
rowKey="fileName"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={backupFiles}
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
pagination={pagination}
|
||||||
|
loading={loading}
|
||||||
|
onChange={handleTableChange}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
265
src/renderer/src/components/S3Modals.tsx
Normal file
265
src/renderer/src/components/S3Modals.tsx
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
import { backupToS3 } from '@renderer/services/BackupService'
|
||||||
|
import { formatFileSize } from '@renderer/utils'
|
||||||
|
import { Input, Modal, Select, Spin } from 'antd'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface BackupFile {
|
||||||
|
fileName: string
|
||||||
|
modifiedTime: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useS3BackupModal() {
|
||||||
|
const [customFileName, setCustomFileName] = useState('')
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false)
|
||||||
|
const [backuping, setBackuping] = useState(false)
|
||||||
|
|
||||||
|
const handleBackup = async () => {
|
||||||
|
setBackuping(true)
|
||||||
|
try {
|
||||||
|
await backupToS3({ customFileName, showMessage: true })
|
||||||
|
} finally {
|
||||||
|
setBackuping(false)
|
||||||
|
setIsModalVisible(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
isModalVisible,
|
||||||
|
handleBackup,
|
||||||
|
handleCancel,
|
||||||
|
backuping,
|
||||||
|
customFileName,
|
||||||
|
setCustomFileName,
|
||||||
|
showBackupModal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type S3BackupModalProps = {
|
||||||
|
isModalVisible: boolean
|
||||||
|
handleBackup: () => Promise<void>
|
||||||
|
handleCancel: () => void
|
||||||
|
backuping: boolean
|
||||||
|
customFileName: string
|
||||||
|
setCustomFileName: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function S3BackupModal({
|
||||||
|
isModalVisible,
|
||||||
|
handleBackup,
|
||||||
|
handleCancel,
|
||||||
|
backuping,
|
||||||
|
customFileName,
|
||||||
|
setCustomFileName
|
||||||
|
}: S3BackupModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('settings.data.s3.backup.modal.title')}
|
||||||
|
open={isModalVisible}
|
||||||
|
onOk={handleBackup}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
okButtonProps={{ loading: backuping }}
|
||||||
|
transitionName="animation-move-down"
|
||||||
|
centered>
|
||||||
|
<Input
|
||||||
|
value={customFileName}
|
||||||
|
onChange={(e) => setCustomFileName(e.target.value)}
|
||||||
|
placeholder={t('settings.data.s3.backup.modal.filename.placeholder')}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseS3RestoreModalProps {
|
||||||
|
endpoint: string | undefined
|
||||||
|
region: string | undefined
|
||||||
|
bucket: string | undefined
|
||||||
|
accessKeyId: string | undefined
|
||||||
|
secretAccessKey: string | undefined
|
||||||
|
root?: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useS3RestoreModal({
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
bucket,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
root
|
||||||
|
}: UseS3RestoreModalProps) {
|
||||||
|
const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false)
|
||||||
|
const [restoring, setRestoring] = useState(false)
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||||
|
const [loadingFiles, setLoadingFiles] = useState(false)
|
||||||
|
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const showRestoreModal = useCallback(async () => {
|
||||||
|
if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
|
||||||
|
window.message.error({ content: t('settings.data.s3.manager.config.incomplete'), key: 's3-error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRestoreModalVisible(true)
|
||||||
|
setLoadingFiles(true)
|
||||||
|
try {
|
||||||
|
const files = await window.api.backup.listS3Files({
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
bucket,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
root,
|
||||||
|
autoSync: false,
|
||||||
|
syncInterval: 0,
|
||||||
|
maxBackups: 0,
|
||||||
|
skipBackupFile: false
|
||||||
|
})
|
||||||
|
setBackupFiles(files)
|
||||||
|
} catch (error: any) {
|
||||||
|
window.message.error({
|
||||||
|
content: t('settings.data.s3.manager.files.fetch.error', { message: error.message }),
|
||||||
|
key: 'list-files-error'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoadingFiles(false)
|
||||||
|
}
|
||||||
|
}, [endpoint, region, bucket, accessKeyId, secretAccessKey, root, t])
|
||||||
|
|
||||||
|
const handleRestore = useCallback(async () => {
|
||||||
|
if (!selectedFile || !endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
|
||||||
|
window.message.error({
|
||||||
|
content: !selectedFile
|
||||||
|
? t('settings.data.s3.restore.file.required')
|
||||||
|
: t('settings.data.s3.restore.config.incomplete'),
|
||||||
|
key: 'restore-error'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.modal.confirm({
|
||||||
|
title: t('settings.data.s3.restore.confirm.title'),
|
||||||
|
content: t('settings.data.s3.restore.confirm.content', { fileName: selectedFile }),
|
||||||
|
okText: t('settings.data.s3.restore.confirm.ok'),
|
||||||
|
cancelText: t('settings.data.s3.restore.confirm.cancel'),
|
||||||
|
centered: true,
|
||||||
|
onOk: async () => {
|
||||||
|
setRestoring(true)
|
||||||
|
try {
|
||||||
|
await window.api.backup.restoreFromS3({
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
bucket,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
root,
|
||||||
|
fileName: selectedFile,
|
||||||
|
autoSync: false,
|
||||||
|
syncInterval: 0,
|
||||||
|
maxBackups: 0,
|
||||||
|
skipBackupFile: false
|
||||||
|
})
|
||||||
|
window.message.success({ content: t('message.restore.success'), key: 's3-restore' })
|
||||||
|
setIsRestoreModalVisible(false)
|
||||||
|
} catch (error: any) {
|
||||||
|
window.message.error({
|
||||||
|
content: t('settings.data.s3.restore.error', { message: error.message }),
|
||||||
|
key: 'restore-error'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setRestoring(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [selectedFile, endpoint, region, bucket, accessKeyId, secretAccessKey, root, t])
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsRestoreModalVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRestoreModalVisible,
|
||||||
|
handleRestore,
|
||||||
|
handleCancel,
|
||||||
|
restoring,
|
||||||
|
selectedFile,
|
||||||
|
setSelectedFile,
|
||||||
|
loadingFiles,
|
||||||
|
backupFiles,
|
||||||
|
showRestoreModal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type S3RestoreModalProps = ReturnType<typeof useS3RestoreModal>
|
||||||
|
|
||||||
|
export function S3RestoreModal({
|
||||||
|
isRestoreModalVisible,
|
||||||
|
handleRestore,
|
||||||
|
handleCancel,
|
||||||
|
restoring,
|
||||||
|
selectedFile,
|
||||||
|
setSelectedFile,
|
||||||
|
loadingFiles,
|
||||||
|
backupFiles
|
||||||
|
}: S3RestoreModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('settings.data.s3.restore.modal.title')}
|
||||||
|
open={isRestoreModalVisible}
|
||||||
|
onOk={handleRestore}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
okButtonProps={{ loading: restoring }}
|
||||||
|
width={600}
|
||||||
|
transitionName="animation-move-down"
|
||||||
|
centered>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder={t('settings.data.s3.restore.modal.select.placeholder')}
|
||||||
|
value={selectedFile}
|
||||||
|
onChange={setSelectedFile}
|
||||||
|
options={backupFiles.map(formatFileOption)}
|
||||||
|
loading={loadingFiles}
|
||||||
|
showSearch
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
typeof option?.label === 'string' ? option.label.toLowerCase().includes(input.toLowerCase()) : false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{loadingFiles && (
|
||||||
|
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileOption(file: BackupFile) {
|
||||||
|
const date = dayjs(file.modifiedTime).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
const size = formatFileSize(file.size)
|
||||||
|
return {
|
||||||
|
label: `${file.fileName} (${date}, ${size})`,
|
||||||
|
value: file.fileName
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1268,6 +1268,71 @@
|
|||||||
"maxBackups": "Maximum Backups",
|
"maxBackups": "Maximum Backups",
|
||||||
"maxBackups.unlimited": "Unlimited"
|
"maxBackups.unlimited": "Unlimited"
|
||||||
},
|
},
|
||||||
|
"s3": {
|
||||||
|
"title": "S3 Compatible Storage",
|
||||||
|
"title.help": "S3 compatible object storage services, such as AWS S3, Cloudflare R2, Aliyun OSS, Tencent COS, etc.",
|
||||||
|
"title.tooltip": "S3 Compatible Storage Configuration Document",
|
||||||
|
"endpoint": "API Endpoint",
|
||||||
|
"endpoint.placeholder": "https://s3.example.com",
|
||||||
|
"region": "Region",
|
||||||
|
"region.placeholder": "Region, e.g: us-east-1",
|
||||||
|
"bucket": "Bucket",
|
||||||
|
"bucket.placeholder": "Bucket, e.g: example",
|
||||||
|
"accessKeyId": "Access Key ID",
|
||||||
|
"accessKeyId.placeholder": "Access Key ID",
|
||||||
|
"secretAccessKey": "Secret Access Key",
|
||||||
|
"secretAccessKey.placeholder": "Secret Access Key",
|
||||||
|
"root": "Backup Directory (Optional)",
|
||||||
|
"root.placeholder": "e.g: /cherry-studio",
|
||||||
|
"backup.operation": "Backup Operation",
|
||||||
|
"backup.button": "Backup Now",
|
||||||
|
"backup.manager.button": "Manage Backups",
|
||||||
|
"backup.modal.title": "S3 Backup",
|
||||||
|
"backup.modal.filename.placeholder": "Please enter backup filename",
|
||||||
|
"backup.success": "S3 backup successful",
|
||||||
|
"backup.error": "S3 backup failed: {{message}}",
|
||||||
|
"autoSync": "Auto Sync",
|
||||||
|
"autoSync.off": "Off",
|
||||||
|
"autoSync.minute": "Every {{count}} minute",
|
||||||
|
"autoSync.hour": "Every {{count}} hour",
|
||||||
|
"maxBackups": "Maximum Backups",
|
||||||
|
"maxBackups.unlimited": "Unlimited",
|
||||||
|
"skipBackupFile": "Lightweight Backup",
|
||||||
|
"skipBackupFile.help": "When enabled, file data will be skipped during backup, only configuration information will be backed up, significantly reducing backup file size",
|
||||||
|
"syncStatus": "Sync Status",
|
||||||
|
"syncStatus.noSync": "Not synced",
|
||||||
|
"syncStatus.error": "Sync error: {{message}}",
|
||||||
|
"syncStatus.lastSync": "Last sync: {{time}}",
|
||||||
|
"manager.title": "S3 Backup File Manager",
|
||||||
|
"manager.refresh": "Refresh",
|
||||||
|
"manager.delete.selected": "Delete Selected ({{count}})",
|
||||||
|
"manager.close": "Close",
|
||||||
|
"manager.columns.fileName": "File Name",
|
||||||
|
"manager.columns.modifiedTime": "Modified Time",
|
||||||
|
"manager.columns.size": "File Size",
|
||||||
|
"manager.columns.actions": "Actions",
|
||||||
|
"manager.restore": "Restore",
|
||||||
|
"manager.delete": "Delete",
|
||||||
|
"manager.config.incomplete": "Please fill in complete S3 configuration",
|
||||||
|
"manager.files.fetch.error": "Failed to fetch backup file list: {{message}}",
|
||||||
|
"manager.delete.confirm.title": "Confirm Delete",
|
||||||
|
"manager.delete.confirm.multiple": "Are you sure you want to delete {{count}} selected backup files? This action cannot be undone.",
|
||||||
|
"manager.delete.confirm.single": "Are you sure you want to delete backup file \"{{fileName}}\"? This action cannot be undone.",
|
||||||
|
"manager.delete.success.multiple": "Successfully deleted {{count}} backup files",
|
||||||
|
"manager.delete.success.single": "Backup file deleted successfully",
|
||||||
|
"manager.delete.error": "Failed to delete backup file: {{message}}",
|
||||||
|
"manager.select.warning": "Please select backup files to delete",
|
||||||
|
"restore.modal.title": "S3 Data Restore",
|
||||||
|
"restore.modal.select.placeholder": "Please select backup file to restore",
|
||||||
|
"restore.confirm.title": "Confirm Restore Data",
|
||||||
|
"restore.confirm.content": "Restoring data will overwrite all current data. This action cannot be undone. Are you sure you want to continue?",
|
||||||
|
"restore.confirm.ok": "Confirm Restore",
|
||||||
|
"restore.confirm.cancel": "Cancel",
|
||||||
|
"restore.success": "Data restore successful",
|
||||||
|
"restore.error": "Data restore failed: {{message}}",
|
||||||
|
"restore.config.incomplete": "Please fill in complete S3 configuration",
|
||||||
|
"restore.file.required": "Please select backup file to restore"
|
||||||
|
},
|
||||||
"yuque": {
|
"yuque": {
|
||||||
"check": {
|
"check": {
|
||||||
"button": "Check",
|
"button": "Check",
|
||||||
|
|||||||
@ -1248,6 +1248,71 @@
|
|||||||
"maxBackups": "最大バックアップ数",
|
"maxBackups": "最大バックアップ数",
|
||||||
"maxBackups.unlimited": "無制限"
|
"maxBackups.unlimited": "無制限"
|
||||||
},
|
},
|
||||||
|
"s3": {
|
||||||
|
"title": "S3互換ストレージ",
|
||||||
|
"title.tooltip": "S3互換ストレージ設定ガイド",
|
||||||
|
"title.help": "AWS S3 APIと互換性のあるオブジェクトストレージサービス(例:AWS S3、Cloudflare R2、Alibaba Cloud OSS、Tencent Cloud COSなど)",
|
||||||
|
"endpoint": "APIエンドポイント",
|
||||||
|
"endpoint.placeholder": "https://s3.example.com",
|
||||||
|
"region": "リージョン",
|
||||||
|
"region.placeholder": "Region、例: us-east-1",
|
||||||
|
"bucket": "バケット",
|
||||||
|
"bucket.placeholder": "Bucket、例: example",
|
||||||
|
"accessKeyId": "Access Key ID",
|
||||||
|
"accessKeyId.placeholder": "Access Key ID",
|
||||||
|
"secretAccessKey": "Secret Access Key",
|
||||||
|
"secretAccessKey.placeholder": "Secret Access Key",
|
||||||
|
"root": "バックアップディレクトリ(オプション)",
|
||||||
|
"root.placeholder": "例:/cherry-studio",
|
||||||
|
"backup.operation": "バックアップ操作",
|
||||||
|
"backup.button": "今すぐバックアップ",
|
||||||
|
"backup.manager.button": "バックアップ管理",
|
||||||
|
"backup.modal.title": "S3バックアップ",
|
||||||
|
"backup.modal.filename.placeholder": "バックアップファイル名を入力してください",
|
||||||
|
"backup.success": "S3バックアップ成功",
|
||||||
|
"backup.error": "S3バックアップ失敗: {{message}}",
|
||||||
|
"autoSync": "自動同期",
|
||||||
|
"autoSync.off": "オフ",
|
||||||
|
"autoSync.minute": "{{count}}分毎",
|
||||||
|
"autoSync.hour": "{{count}}時間毎",
|
||||||
|
"maxBackups": "最大バックアップ数",
|
||||||
|
"maxBackups.unlimited": "無制限",
|
||||||
|
"skipBackupFile": "軽量バックアップ",
|
||||||
|
"skipBackupFile.help": "有効にすると、バックアップ時にファイルデータがスキップされ、設定情報のみがバックアップされ、バックアップファイルのサイズが大幅に削減されます。",
|
||||||
|
"syncStatus": "同期ステータス",
|
||||||
|
"syncStatus.noSync": "未同期",
|
||||||
|
"syncStatus.error": "同期エラー: {{message}}",
|
||||||
|
"syncStatus.lastSync": "最終同期: {{time}}",
|
||||||
|
"manager.title": "S3バックアップファイルマネージャー",
|
||||||
|
"manager.refresh": "更新",
|
||||||
|
"manager.delete.selected": "選択項目を削除 ({{count}})",
|
||||||
|
"manager.close": "閉じる",
|
||||||
|
"manager.columns.fileName": "ファイル名",
|
||||||
|
"manager.columns.modifiedTime": "変更日時",
|
||||||
|
"manager.columns.size": "ファイルサイズ",
|
||||||
|
"manager.columns.actions": "操作",
|
||||||
|
"manager.restore": "復元",
|
||||||
|
"manager.delete": "削除",
|
||||||
|
"manager.config.incomplete": "完全なS3設定情報を入力してください",
|
||||||
|
"manager.files.fetch.error": "バックアップファイルリストの取得に失敗しました: {{message}}",
|
||||||
|
"manager.delete.confirm.title": "削除の確認",
|
||||||
|
"manager.delete.confirm.multiple": "選択した{{count}}個のバックアップファイルを削除してもよろしいですか?この操作は元に戻せません。",
|
||||||
|
"manager.delete.confirm.single": "バックアップファイル「{{fileName}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||||
|
"manager.delete.success.multiple": "{{count}}個のバックアップファイルを正常に削除しました",
|
||||||
|
"manager.delete.success.single": "バックアップファイルの削除に成功しました",
|
||||||
|
"manager.delete.error": "バックアップファイルの削除に失敗しました: {{message}}",
|
||||||
|
"manager.select.warning": "削除するバックアップファイルを選択してください",
|
||||||
|
"restore.modal.title": "S3データ復元",
|
||||||
|
"restore.modal.select.placeholder": "復元するバックアップファイルを選択してください",
|
||||||
|
"restore.confirm.title": "データ復元の確認",
|
||||||
|
"restore.confirm.content": "データを復元すると、現在のすべてのデータが上書きされます。この操作は元に戻せません。続行してもよろしいですか?",
|
||||||
|
"restore.confirm.ok": "復元を確認",
|
||||||
|
"restore.confirm.cancel": "キャンセル",
|
||||||
|
"restore.success": "データの復元に成功しました",
|
||||||
|
"restore.error": "データの復元に失敗しました: {{message}}",
|
||||||
|
"restore.config.incomplete": "完全なS3設定情報を入力してください",
|
||||||
|
"restore.file.required": "復元するバックアップファイルを選択してください"
|
||||||
|
},
|
||||||
"yuque": {
|
"yuque": {
|
||||||
"check": {
|
"check": {
|
||||||
"button": "接続確認",
|
"button": "接続確認",
|
||||||
|
|||||||
@ -1266,6 +1266,71 @@
|
|||||||
"maxBackups": "Максимальное количество резервных копий",
|
"maxBackups": "Максимальное количество резервных копий",
|
||||||
"maxBackups.unlimited": "Без ограничений"
|
"maxBackups.unlimited": "Без ограничений"
|
||||||
},
|
},
|
||||||
|
"s3": {
|
||||||
|
"title": "S3-совместимое хранилище",
|
||||||
|
"title.tooltip": "Руководство по настройке S3-совместимого хранилища",
|
||||||
|
"title.help": "Сервисы объектного хранения, совместимые с AWS S3 API, такие как AWS S3, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS и т.д.",
|
||||||
|
"endpoint": "Конечная точка API",
|
||||||
|
"endpoint.placeholder": "https://s3.example.com",
|
||||||
|
"region": "Регион",
|
||||||
|
"region.placeholder": "Регион, например: us-east-1",
|
||||||
|
"bucket": "Корзина",
|
||||||
|
"bucket.placeholder": "Корзина, например: example",
|
||||||
|
"accessKeyId": "Access Key ID",
|
||||||
|
"accessKeyId.placeholder": "Access Key ID",
|
||||||
|
"secretAccessKey": "Secret Access Key",
|
||||||
|
"secretAccessKey.placeholder": "Secret Access Key",
|
||||||
|
"root": "Каталог резервных копий (необязательно)",
|
||||||
|
"root.placeholder": "например: /cherry-studio",
|
||||||
|
"backup.operation": "Операция резервного копирования",
|
||||||
|
"backup.button": "Создать резервную копию сейчас",
|
||||||
|
"backup.manager.button": "Управление резервными копиями",
|
||||||
|
"backup.modal.title": "Резервное копирование S3",
|
||||||
|
"backup.modal.filename.placeholder": "Пожалуйста, введите имя файла резервной копии",
|
||||||
|
"backup.success": "Резервное копирование S3 успешно",
|
||||||
|
"backup.error": "Ошибка резервного копирования S3: {{message}}",
|
||||||
|
"autoSync": "Автосинхронизация",
|
||||||
|
"autoSync.off": "Выкл.",
|
||||||
|
"autoSync.minute": "Каждые {{count}} мин.",
|
||||||
|
"autoSync.hour": "Каждые {{count}} ч.",
|
||||||
|
"maxBackups": "Макс. резервных копий",
|
||||||
|
"maxBackups.unlimited": "Неограниченно",
|
||||||
|
"skipBackupFile": "Облегченное резервное копирование",
|
||||||
|
"skipBackupFile.help": "Если включено, данные файлов будут пропущены во время резервного копирования, будет скопирована только информация о конфигурации, что значительно уменьшит размер файла резервной копии.",
|
||||||
|
"syncStatus": "Статус синхронизации",
|
||||||
|
"syncStatus.noSync": "Не синхронизировано",
|
||||||
|
"syncStatus.error": "Ошибка синхронизации: {{message}}",
|
||||||
|
"syncStatus.lastSync": "Последняя синхронизация: {{time}}",
|
||||||
|
"manager.title": "Менеджер файлов резервных копий S3",
|
||||||
|
"manager.refresh": "Обновить",
|
||||||
|
"manager.delete.selected": "Удалить выбранные ({{count}})",
|
||||||
|
"manager.close": "Закрыть",
|
||||||
|
"manager.columns.fileName": "Имя файла",
|
||||||
|
"manager.columns.modifiedTime": "Время изменения",
|
||||||
|
"manager.columns.size": "Размер файла",
|
||||||
|
"manager.columns.actions": "Действия",
|
||||||
|
"manager.restore": "Восстановить",
|
||||||
|
"manager.delete": "Удалить",
|
||||||
|
"manager.config.incomplete": "Пожалуйста, заполните полную конфигурацию S3",
|
||||||
|
"manager.files.fetch.error": "Не удалось получить список файлов резервных копий: {{message}}",
|
||||||
|
"manager.delete.confirm.title": "Подтвердить удаление",
|
||||||
|
"manager.delete.confirm.multiple": "Вы уверены, что хотите удалить {{count}} выбранных файлов резервных копий? Это действие нельзя отменить.",
|
||||||
|
"manager.delete.confirm.single": "Вы уверены, что хотите удалить файл резервной копии \"{{fileName}}\"? Это действие нельзя отменить.",
|
||||||
|
"manager.delete.success.multiple": "Успешно удалено {{count}} файлов резервных копий",
|
||||||
|
"manager.delete.success.single": "Файл резервной копии успешно удален",
|
||||||
|
"manager.delete.error": "Не удалось удалить файл резервной копии: {{message}}",
|
||||||
|
"manager.select.warning": "Пожалуйста, выберите файлы резервных копий для удаления",
|
||||||
|
"restore.modal.title": "Восстановление данных S3",
|
||||||
|
"restore.modal.select.placeholder": "Пожалуйста, выберите файл резервной копии для восстановления",
|
||||||
|
"restore.confirm.title": "Подтвердить восстановление данных",
|
||||||
|
"restore.confirm.content": "Восстановление данных перезапишет все текущие данные. Это действие нельзя отменить. Вы уверены, что хотите продолжить?",
|
||||||
|
"restore.confirm.ok": "Подтвердить восстановление",
|
||||||
|
"restore.confirm.cancel": "Отмена",
|
||||||
|
"restore.success": "Восстановление данных успешно",
|
||||||
|
"restore.error": "Ошибка восстановления данных: {{message}}",
|
||||||
|
"restore.config.incomplete": "Пожалуйста, заполните полную конфигурацию S3",
|
||||||
|
"restore.file.required": "Пожалуйста, выберите файл резервной копии для восстановления"
|
||||||
|
},
|
||||||
"yuque": {
|
"yuque": {
|
||||||
"check": {
|
"check": {
|
||||||
"button": "Проверить",
|
"button": "Проверить",
|
||||||
|
|||||||
@ -1268,7 +1268,72 @@
|
|||||||
"title": "WebDAV",
|
"title": "WebDAV",
|
||||||
"user": "WebDAV 用户名",
|
"user": "WebDAV 用户名",
|
||||||
"maxBackups": "最大备份数",
|
"maxBackups": "最大备份数",
|
||||||
"maxBackups.unlimited": "无限制"
|
"maxBackups.unlimited": "不限"
|
||||||
|
},
|
||||||
|
"s3": {
|
||||||
|
"title": "S3 兼容存储",
|
||||||
|
"title.tooltip": "S3 兼容存储配置文档",
|
||||||
|
"title.help": "与AWS S3 API兼容的对象存储服务, 例如AWS S3, Cloudflare R2, 阿里云OSS, 腾讯云COS等",
|
||||||
|
"endpoint": "API 地址",
|
||||||
|
"endpoint.placeholder": "https://s3.example.com",
|
||||||
|
"region": "区域",
|
||||||
|
"region.placeholder": "Region, 例如: us-east-1",
|
||||||
|
"bucket": "存储桶",
|
||||||
|
"bucket.placeholder": "Bucket, 例如: example",
|
||||||
|
"accessKeyId": "Access Key ID",
|
||||||
|
"accessKeyId.placeholder": "Access Key ID",
|
||||||
|
"secretAccessKey": "Secret Access Key",
|
||||||
|
"secretAccessKey.placeholder": "Secret Access Key",
|
||||||
|
"root": "备份目录(可选)",
|
||||||
|
"root.placeholder": "例如:/cherry-studio",
|
||||||
|
"backup.operation": "备份操作",
|
||||||
|
"backup.button": "立即备份",
|
||||||
|
"backup.manager.button": "管理备份",
|
||||||
|
"backup.modal.title": "S3 备份",
|
||||||
|
"backup.modal.filename.placeholder": "请输入备份文件名",
|
||||||
|
"backup.success": "S3 备份成功",
|
||||||
|
"backup.error": "S3 备份失败: {{message}}",
|
||||||
|
"autoSync": "自动同步",
|
||||||
|
"autoSync.off": "关闭",
|
||||||
|
"autoSync.minute": "每 {{count}} 分钟",
|
||||||
|
"autoSync.hour": "每 {{count}} 小时",
|
||||||
|
"maxBackups": "最大备份数",
|
||||||
|
"maxBackups.unlimited": "不限",
|
||||||
|
"skipBackupFile": "精简备份",
|
||||||
|
"skipBackupFile.help": "开启后备份时将跳过文件数据,仅备份配置信息,显著减小备份文件体积",
|
||||||
|
"syncStatus": "同步状态",
|
||||||
|
"syncStatus.noSync": "未同步",
|
||||||
|
"syncStatus.error": "同步错误: {{message}}",
|
||||||
|
"syncStatus.lastSync": "上次同步: {{time}}",
|
||||||
|
"manager.title": "S3 备份文件管理",
|
||||||
|
"manager.refresh": "刷新",
|
||||||
|
"manager.delete.selected": "删除选中 ({{count}})",
|
||||||
|
"manager.close": "关闭",
|
||||||
|
"manager.columns.fileName": "文件名",
|
||||||
|
"manager.columns.modifiedTime": "修改时间",
|
||||||
|
"manager.columns.size": "文件大小",
|
||||||
|
"manager.columns.actions": "操作",
|
||||||
|
"manager.restore": "恢复",
|
||||||
|
"manager.delete": "删除",
|
||||||
|
"manager.config.incomplete": "请填写完整的 S3 配置信息",
|
||||||
|
"manager.files.fetch.error": "获取备份文件列表失败: {{message}}",
|
||||||
|
"manager.delete.confirm.title": "确认删除",
|
||||||
|
"manager.delete.confirm.multiple": "确定要删除选中的 {{count}} 个备份文件吗?此操作不可撤销。",
|
||||||
|
"manager.delete.confirm.single": "确定要删除备份文件 \"{{fileName}}\" 吗?此操作不可撤销。",
|
||||||
|
"manager.delete.success.multiple": "成功删除 {{count}} 个备份文件",
|
||||||
|
"manager.delete.success.single": "删除备份文件成功",
|
||||||
|
"manager.delete.error": "删除备份文件失败: {{message}}",
|
||||||
|
"manager.select.warning": "请选择要删除的备份文件",
|
||||||
|
"restore.modal.title": "S3 数据恢复",
|
||||||
|
"restore.modal.select.placeholder": "请选择要恢复的备份文件",
|
||||||
|
"restore.confirm.title": "确认恢复数据",
|
||||||
|
"restore.confirm.content": "恢复数据将覆盖当前所有数据,此操作不可撤销。确定要继续吗?",
|
||||||
|
"restore.confirm.ok": "确认恢复",
|
||||||
|
"restore.confirm.cancel": "取消",
|
||||||
|
"restore.success": "数据恢复成功",
|
||||||
|
"restore.error": "数据恢复失败: {{message}}",
|
||||||
|
"restore.config.incomplete": "请填写完整的 S3 配置信息",
|
||||||
|
"restore.file.required": "请选择要恢复的备份文件"
|
||||||
},
|
},
|
||||||
"yuque": {
|
"yuque": {
|
||||||
"check": {
|
"check": {
|
||||||
|
|||||||
@ -1266,7 +1266,72 @@
|
|||||||
"title": "WebDAV",
|
"title": "WebDAV",
|
||||||
"user": "WebDAV 使用者名稱",
|
"user": "WebDAV 使用者名稱",
|
||||||
"maxBackups": "最大備份數量",
|
"maxBackups": "最大備份數量",
|
||||||
"maxBackups.unlimited": "無限制"
|
"maxBackups.unlimited": "不限"
|
||||||
|
},
|
||||||
|
"s3": {
|
||||||
|
"title": "S3 相容儲存",
|
||||||
|
"title.tooltip": "S3 相容儲存設定指南",
|
||||||
|
"title.help": "與AWS S3 API相容的物件儲存服務,例如AWS S3、Cloudflare R2、阿里雲OSS、騰訊雲COS等",
|
||||||
|
"endpoint": "API 位址",
|
||||||
|
"endpoint.placeholder": "https://s3.example.com",
|
||||||
|
"region": "區域",
|
||||||
|
"region.placeholder": "Region,例如: us-east-1",
|
||||||
|
"bucket": "儲存桶",
|
||||||
|
"bucket.placeholder": "Bucket,例如: example",
|
||||||
|
"accessKeyId": "Access Key ID",
|
||||||
|
"accessKeyId.placeholder": "Access Key ID",
|
||||||
|
"secretAccessKey": "Secret Access Key",
|
||||||
|
"secretAccessKey.placeholder": "Secret Access Key",
|
||||||
|
"root": "備份目錄(可選)",
|
||||||
|
"root.placeholder": "例如:/cherry-studio",
|
||||||
|
"backup.operation": "備份操作",
|
||||||
|
"backup.button": "立即備份",
|
||||||
|
"backup.manager.button": "管理備份",
|
||||||
|
"backup.modal.title": "S3 備份",
|
||||||
|
"backup.modal.filename.placeholder": "請輸入備份檔案名稱",
|
||||||
|
"backup.success": "S3 備份成功",
|
||||||
|
"backup.error": "S3 備份失敗: {{message}}",
|
||||||
|
"autoSync": "自動同步",
|
||||||
|
"autoSync.off": "關閉",
|
||||||
|
"autoSync.minute": "每 {{count}} 分鐘",
|
||||||
|
"autoSync.hour": "每 {{count}} 小時",
|
||||||
|
"maxBackups": "最大備份數",
|
||||||
|
"maxBackups.unlimited": "不限",
|
||||||
|
"skipBackupFile": "精簡備份",
|
||||||
|
"skipBackupFile.help": "開啟後備份時將跳過檔案資料,僅備份設定資訊,顯著減小備份檔案體積",
|
||||||
|
"syncStatus": "同步狀態",
|
||||||
|
"syncStatus.noSync": "未同步",
|
||||||
|
"syncStatus.error": "同步錯誤: {{message}}",
|
||||||
|
"syncStatus.lastSync": "上次同步: {{time}}",
|
||||||
|
"manager.title": "S3 備份檔案管理",
|
||||||
|
"manager.refresh": "重新整理",
|
||||||
|
"manager.delete.selected": "刪除選中 ({{count}})",
|
||||||
|
"manager.close": "關閉",
|
||||||
|
"manager.columns.fileName": "檔案名稱",
|
||||||
|
"manager.columns.modifiedTime": "修改時間",
|
||||||
|
"manager.columns.size": "檔案大小",
|
||||||
|
"manager.columns.actions": "操作",
|
||||||
|
"manager.restore": "恢復",
|
||||||
|
"manager.delete": "刪除",
|
||||||
|
"manager.config.incomplete": "請填寫完整的 S3 設定資訊",
|
||||||
|
"manager.files.fetch.error": "取得備份檔案清單失敗: {{message}}",
|
||||||
|
"manager.delete.confirm.title": "確認刪除",
|
||||||
|
"manager.delete.confirm.multiple": "確定要刪除選中的 {{count}} 個備份檔案嗎?此操作不可撤銷。",
|
||||||
|
"manager.delete.confirm.single": "確定要刪除備份檔案 \"{{fileName}}\" 嗎?此操作不可撤銷。",
|
||||||
|
"manager.delete.success.multiple": "成功刪除 {{count}} 個備份檔案",
|
||||||
|
"manager.delete.success.single": "刪除備份檔案成功",
|
||||||
|
"manager.delete.error": "刪除備份檔案失敗: {{message}}",
|
||||||
|
"manager.select.warning": "請選擇要刪除的備份檔案",
|
||||||
|
"restore.modal.title": "S3 資料恢復",
|
||||||
|
"restore.modal.select.placeholder": "請選擇要恢復的備份檔案",
|
||||||
|
"restore.confirm.title": "確認恢復資料",
|
||||||
|
"restore.confirm.content": "恢復資料將覆寫當前所有資料,此操作不可撤銷。確定要繼續嗎?",
|
||||||
|
"restore.confirm.ok": "確認恢復",
|
||||||
|
"restore.confirm.cancel": "取消",
|
||||||
|
"restore.success": "資料恢復成功",
|
||||||
|
"restore.error": "資料恢復失敗: {{message}}",
|
||||||
|
"restore.config.incomplete": "請填寫完整的 S3 設定資訊",
|
||||||
|
"restore.file.required": "請選擇要恢復的備份檔案"
|
||||||
},
|
},
|
||||||
"yuque": {
|
"yuque": {
|
||||||
"check": {
|
"check": {
|
||||||
|
|||||||
@ -12,9 +12,9 @@ function initKeyv() {
|
|||||||
|
|
||||||
function initAutoSync() {
|
function initAutoSync() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const { webdavAutoSync } = store.getState().settings
|
const { webdavAutoSync, s3 } = store.getState().settings
|
||||||
const { nutstoreAutoSync } = store.getState().nutstore
|
const { nutstoreAutoSync } = store.getState().nutstore
|
||||||
if (webdavAutoSync) {
|
if (webdavAutoSync || (s3 && s3.autoSync)) {
|
||||||
startAutoSync()
|
startAutoSync()
|
||||||
}
|
}
|
||||||
if (nutstoreAutoSync) {
|
if (nutstoreAutoSync) {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
CloudServerOutlined,
|
||||||
CloudSyncOutlined,
|
CloudSyncOutlined,
|
||||||
FileSearchOutlined,
|
FileSearchOutlined,
|
||||||
FolderOpenOutlined,
|
FolderOpenOutlined,
|
||||||
@ -42,6 +43,7 @@ import MarkdownExportSettings from './MarkdownExportSettings'
|
|||||||
import NotionSettings from './NotionSettings'
|
import NotionSettings from './NotionSettings'
|
||||||
import NutstoreSettings from './NutstoreSettings'
|
import NutstoreSettings from './NutstoreSettings'
|
||||||
import ObsidianSettings from './ObsidianSettings'
|
import ObsidianSettings from './ObsidianSettings'
|
||||||
|
import S3Settings from './S3Settings'
|
||||||
import SiyuanSettings from './SiyuanSettings'
|
import SiyuanSettings from './SiyuanSettings'
|
||||||
import WebDavSettings from './WebDavSettings'
|
import WebDavSettings from './WebDavSettings'
|
||||||
import YuqueSettings from './YuqueSettings'
|
import YuqueSettings from './YuqueSettings'
|
||||||
@ -88,6 +90,7 @@ const DataSettings: FC = () => {
|
|||||||
{ 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: '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: 'divider_2', isDivider: true, text: t('settings.data.divider.export_settings') },
|
{ key: 'divider_2', isDivider: true, text: t('settings.data.divider.export_settings') },
|
||||||
{
|
{
|
||||||
key: 'export_menu',
|
key: 'export_menu',
|
||||||
@ -653,6 +656,7 @@ const DataSettings: FC = () => {
|
|||||||
)}
|
)}
|
||||||
{menu === 'webdav' && <WebDavSettings />}
|
{menu === 'webdav' && <WebDavSettings />}
|
||||||
{menu === 'nutstore' && <NutstoreSettings />}
|
{menu === 'nutstore' && <NutstoreSettings />}
|
||||||
|
{menu === 's3' && <S3Settings />}
|
||||||
{menu === 'export_menu' && <ExportMenuOptions />}
|
{menu === 'export_menu' && <ExportMenuOptions />}
|
||||||
{menu === 'markdown_export' && <MarkdownExportSettings />}
|
{menu === 'markdown_export' && <MarkdownExportSettings />}
|
||||||
{menu === 'notion' && <NotionSettings />}
|
{menu === 'notion' && <NotionSettings />}
|
||||||
@ -686,8 +690,12 @@ const MenuList = styled.div`
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
width: var(--settings-width);
|
width: var(--settings-width);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
padding-bottom: 48px;
|
||||||
border-right: 0.5px solid var(--color-border);
|
border-right: 0.5px solid var(--color-border);
|
||||||
height: 100%;
|
height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 0;
|
||||||
.iconfont {
|
.iconfont {
|
||||||
color: var(--color-text-2);
|
color: var(--color-text-2);
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
|
|||||||
292
src/renderer/src/pages/settings/DataSettings/S3Settings.tsx
Normal file
292
src/renderer/src/pages/settings/DataSettings/S3Settings.tsx
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
import { FolderOpenOutlined, InfoCircleOutlined, SaveOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons'
|
||||||
|
import { HStack } from '@renderer/components/Layout'
|
||||||
|
import { S3BackupManager } from '@renderer/components/S3BackupManager'
|
||||||
|
import { S3BackupModal, useS3BackupModal } from '@renderer/components/S3Modals'
|
||||||
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
|
import { startAutoSync, stopAutoSync } from '@renderer/services/BackupService'
|
||||||
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
|
import { setS3Partial } from '@renderer/store/settings'
|
||||||
|
import { S3Config } from '@renderer/types'
|
||||||
|
import { Button, Input, Select, Switch, Tooltip } from 'antd'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { FC, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||||
|
|
||||||
|
const S3Settings: FC = () => {
|
||||||
|
const { s3 = {} as S3Config } = useSettings()
|
||||||
|
|
||||||
|
const {
|
||||||
|
endpoint: s3EndpointInit = '',
|
||||||
|
region: s3RegionInit = '',
|
||||||
|
bucket: s3BucketInit = '',
|
||||||
|
accessKeyId: s3AccessKeyIdInit = '',
|
||||||
|
secretAccessKey: s3SecretAccessKeyInit = '',
|
||||||
|
root: s3RootInit = '',
|
||||||
|
syncInterval: s3SyncIntervalInit = 0,
|
||||||
|
maxBackups: s3MaxBackupsInit = 5,
|
||||||
|
skipBackupFile: s3SkipBackupFileInit = false
|
||||||
|
} = s3
|
||||||
|
|
||||||
|
const [endpoint, setEndpoint] = useState<string | undefined>(s3EndpointInit)
|
||||||
|
const [region, setRegion] = useState<string | undefined>(s3RegionInit)
|
||||||
|
const [bucket, setBucket] = useState<string | undefined>(s3BucketInit)
|
||||||
|
const [accessKeyId, setAccessKeyId] = useState<string | undefined>(s3AccessKeyIdInit)
|
||||||
|
const [secretAccessKey, setSecretAccessKey] = useState<string | undefined>(s3SecretAccessKeyInit)
|
||||||
|
const [root, setRoot] = useState<string | undefined>(s3RootInit)
|
||||||
|
const [skipBackupFile, setSkipBackupFile] = useState<boolean>(s3SkipBackupFileInit)
|
||||||
|
const [backupManagerVisible, setBackupManagerVisible] = useState(false)
|
||||||
|
|
||||||
|
const [syncInterval, setSyncInterval] = useState<number>(s3SyncIntervalInit)
|
||||||
|
const [maxBackups, setMaxBackups] = useState<number>(s3MaxBackupsInit)
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { openMinapp } = useMinappPopup()
|
||||||
|
|
||||||
|
const { s3Sync } = useAppSelector((state) => state.backup)
|
||||||
|
|
||||||
|
const onSyncIntervalChange = (value: number) => {
|
||||||
|
setSyncInterval(value)
|
||||||
|
dispatch(setS3Partial({ syncInterval: value, autoSync: value !== 0 }))
|
||||||
|
if (value === 0) {
|
||||||
|
stopAutoSync()
|
||||||
|
} else {
|
||||||
|
startAutoSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTitleClick = () => {
|
||||||
|
openMinapp({
|
||||||
|
id: 's3-help',
|
||||||
|
name: 'S3 Compatible Storage Help',
|
||||||
|
url: 'https://docs.cherry-ai.com/data-settings/s3-compatible'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMaxBackupsChange = (value: number) => {
|
||||||
|
setMaxBackups(value)
|
||||||
|
dispatch(setS3Partial({ maxBackups: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSkipBackupFilesChange = (value: boolean) => {
|
||||||
|
setSkipBackupFile(value)
|
||||||
|
dispatch(setS3Partial({ skipBackupFile: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSyncStatus = () => {
|
||||||
|
if (!endpoint) return null
|
||||||
|
|
||||||
|
if (!s3Sync?.lastSyncTime && !s3Sync?.syncing && !s3Sync?.lastSyncError) {
|
||||||
|
return <span style={{ color: 'var(--text-secondary)' }}>{t('settings.data.s3.syncStatus.noSync')}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack gap="5px" alignItems="center">
|
||||||
|
{s3Sync?.syncing && <SyncOutlined spin />}
|
||||||
|
{!s3Sync?.syncing && s3Sync?.lastSyncError && (
|
||||||
|
<Tooltip title={t('settings.data.s3.syncStatus.error', { message: s3Sync.lastSyncError })}>
|
||||||
|
<WarningOutlined style={{ color: 'red' }} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{s3Sync?.lastSyncTime && (
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{t('settings.data.s3.syncStatus.lastSync', { time: dayjs(s3Sync.lastSyncTime).format('HH:mm:ss') })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isModalVisible, handleBackup, handleCancel, backuping, customFileName, setCustomFileName, showBackupModal } =
|
||||||
|
useS3BackupModal()
|
||||||
|
|
||||||
|
const showBackupManager = () => {
|
||||||
|
setBackupManagerVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeBackupManager = () => {
|
||||||
|
setBackupManagerVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingGroup theme={theme}>
|
||||||
|
<SettingTitle style={{ justifyContent: 'flex-start', gap: 10 }}>
|
||||||
|
{t('settings.data.s3.title')}
|
||||||
|
<Tooltip title={t('settings.data.s3.title.tooltip')} placement="right">
|
||||||
|
<InfoCircleOutlined style={{ color: 'var(--color-text-2)', cursor: 'pointer' }} onClick={handleTitleClick} />
|
||||||
|
</Tooltip>
|
||||||
|
</SettingTitle>
|
||||||
|
<SettingHelpText>{t('settings.data.s3.title.help')}</SettingHelpText>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.data.s3.endpoint')}</SettingRowTitle>
|
||||||
|
<Input
|
||||||
|
placeholder={t('settings.data.s3.endpoint.placeholder')}
|
||||||
|
value={endpoint}
|
||||||
|
onChange={(e) => setEndpoint(e.target.value)}
|
||||||
|
style={{ width: 250 }}
|
||||||
|
type="url"
|
||||||
|
onBlur={() => dispatch(setS3Partial({ endpoint: endpoint || '' }))}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.data.s3.region')}</SettingRowTitle>
|
||||||
|
<Input
|
||||||
|
placeholder={t('settings.data.s3.region.placeholder')}
|
||||||
|
value={region}
|
||||||
|
onChange={(e) => setRegion(e.target.value)}
|
||||||
|
style={{ width: 250 }}
|
||||||
|
onBlur={() => dispatch(setS3Partial({ region: region || '' }))}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.data.s3.bucket')}</SettingRowTitle>
|
||||||
|
<Input
|
||||||
|
placeholder={t('settings.data.s3.bucket.placeholder')}
|
||||||
|
value={bucket}
|
||||||
|
onChange={(e) => setBucket(e.target.value)}
|
||||||
|
style={{ width: 250 }}
|
||||||
|
onBlur={() => dispatch(setS3Partial({ bucket: bucket || '' }))}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.data.s3.accessKeyId')}</SettingRowTitle>
|
||||||
|
<Input
|
||||||
|
placeholder={t('settings.data.s3.accessKeyId.placeholder')}
|
||||||
|
value={accessKeyId}
|
||||||
|
onChange={(e) => setAccessKeyId(e.target.value)}
|
||||||
|
style={{ width: 250 }}
|
||||||
|
onBlur={() => dispatch(setS3Partial({ accessKeyId: accessKeyId || '' }))}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.data.s3.secretAccessKey')}</SettingRowTitle>
|
||||||
|
<Input.Password
|
||||||
|
placeholder={t('settings.data.s3.secretAccessKey.placeholder')}
|
||||||
|
value={secretAccessKey}
|
||||||
|
onChange={(e) => setSecretAccessKey(e.target.value)}
|
||||||
|
style={{ width: 250 }}
|
||||||
|
onBlur={() => dispatch(setS3Partial({ secretAccessKey: secretAccessKey || '' }))}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.data.s3.root')}</SettingRowTitle>
|
||||||
|
<Input
|
||||||
|
placeholder={t('settings.data.s3.root.placeholder')}
|
||||||
|
value={root}
|
||||||
|
onChange={(e) => setRoot(e.target.value)}
|
||||||
|
style={{ width: 250 }}
|
||||||
|
onBlur={() => dispatch(setS3Partial({ root: root || '' }))}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.data.s3.backup.operation')}</SettingRowTitle>
|
||||||
|
<HStack gap="5px" justifyContent="space-between">
|
||||||
|
<Button
|
||||||
|
onClick={showBackupModal}
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
loading={backuping}
|
||||||
|
disabled={!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey}>
|
||||||
|
{t('settings.data.s3.backup.button')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={showBackupManager}
|
||||||
|
icon={<FolderOpenOutlined />}
|
||||||
|
disabled={!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey}>
|
||||||
|
{t('settings.data.s3.backup.manager.button')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.data.s3.autoSync')}</SettingRowTitle>
|
||||||
|
<Select
|
||||||
|
value={syncInterval}
|
||||||
|
onChange={onSyncIntervalChange}
|
||||||
|
disabled={!endpoint || !accessKeyId || !secretAccessKey}
|
||||||
|
style={{ width: 120 }}>
|
||||||
|
<Select.Option value={0}>{t('settings.data.s3.autoSync.off')}</Select.Option>
|
||||||
|
<Select.Option value={1}>{t('settings.data.s3.autoSync.minute', { count: 1 })}</Select.Option>
|
||||||
|
<Select.Option value={5}>{t('settings.data.s3.autoSync.minute', { count: 5 })}</Select.Option>
|
||||||
|
<Select.Option value={15}>{t('settings.data.s3.autoSync.minute', { count: 15 })}</Select.Option>
|
||||||
|
<Select.Option value={30}>{t('settings.data.s3.autoSync.minute', { count: 30 })}</Select.Option>
|
||||||
|
<Select.Option value={60}>{t('settings.data.s3.autoSync.hour', { count: 1 })}</Select.Option>
|
||||||
|
<Select.Option value={120}>{t('settings.data.s3.autoSync.hour', { count: 2 })}</Select.Option>
|
||||||
|
<Select.Option value={360}>{t('settings.data.s3.autoSync.hour', { count: 6 })}</Select.Option>
|
||||||
|
<Select.Option value={720}>{t('settings.data.s3.autoSync.hour', { count: 12 })}</Select.Option>
|
||||||
|
<Select.Option value={1440}>{t('settings.data.s3.autoSync.hour', { count: 24 })}</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.data.s3.maxBackups')}</SettingRowTitle>
|
||||||
|
<Select
|
||||||
|
value={maxBackups}
|
||||||
|
onChange={onMaxBackupsChange}
|
||||||
|
disabled={!endpoint || !accessKeyId || !secretAccessKey}
|
||||||
|
style={{ width: 120 }}>
|
||||||
|
<Select.Option value={0}>{t('settings.data.s3.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.s3.skipBackupFile')}</SettingRowTitle>
|
||||||
|
<Switch checked={skipBackupFile} onChange={onSkipBackupFilesChange} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow>
|
||||||
|
<SettingHelpText>{t('settings.data.s3.skipBackupFile.help')}</SettingHelpText>
|
||||||
|
</SettingRow>
|
||||||
|
{syncInterval > 0 && (
|
||||||
|
<>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.data.s3.syncStatus')}</SettingRowTitle>
|
||||||
|
{renderSyncStatus()}
|
||||||
|
</SettingRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<>
|
||||||
|
<S3BackupModal
|
||||||
|
isModalVisible={isModalVisible}
|
||||||
|
handleBackup={handleBackup}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
backuping={backuping}
|
||||||
|
customFileName={customFileName}
|
||||||
|
setCustomFileName={setCustomFileName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<S3BackupManager
|
||||||
|
visible={backupManagerVisible}
|
||||||
|
onClose={closeBackupManager}
|
||||||
|
s3Config={{
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
bucket,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
root
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
</SettingGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default S3Settings
|
||||||
@ -4,11 +4,63 @@ import { upgradeToV7 } 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 { setWebDAVSyncState } from '@renderer/store/backup'
|
||||||
|
import { setS3SyncState } from '@renderer/store/backup'
|
||||||
|
import { S3Config, WebDavConfig } from '@renderer/types'
|
||||||
import { uuid } from '@renderer/utils'
|
import { uuid } from '@renderer/utils'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
import { NotificationService } from './NotificationService'
|
import { NotificationService } from './NotificationService'
|
||||||
|
|
||||||
|
// 重试删除S3文件的辅助函数
|
||||||
|
async function deleteS3FileWithRetry(fileName: string, s3Config: S3Config, maxRetries = 3) {
|
||||||
|
let lastError: Error | null = null
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await window.api.backup.deleteS3File(fileName, s3Config)
|
||||||
|
Logger.log(`[Backup] Successfully deleted old backup file: ${fileName} (attempt ${attempt})`)
|
||||||
|
return true
|
||||||
|
} catch (error: any) {
|
||||||
|
lastError = error
|
||||||
|
Logger.warn(`[Backup] Delete attempt ${attempt}/${maxRetries} failed for ${fileName}:`, error.message)
|
||||||
|
|
||||||
|
// 如果不是最后一次尝试,等待一段时间再重试
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = attempt * 1000 + Math.random() * 1000 // 1-2秒的随机延迟
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error(`[Backup] Failed to delete old backup file after ${maxRetries} attempts: ${fileName}`, lastError)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重试删除WebDAV文件的辅助函数
|
||||||
|
async function deleteWebdavFileWithRetry(fileName: string, webdavConfig: WebDavConfig, maxRetries = 3) {
|
||||||
|
let lastError: Error | null = null
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await window.api.backup.deleteWebdavFile(fileName, webdavConfig)
|
||||||
|
Logger.log(`[Backup] Successfully deleted old backup file: ${fileName} (attempt ${attempt})`)
|
||||||
|
return true
|
||||||
|
} catch (error: any) {
|
||||||
|
lastError = error
|
||||||
|
Logger.warn(`[Backup] Delete attempt ${attempt}/${maxRetries} failed for ${fileName}:`, error.message)
|
||||||
|
|
||||||
|
// 如果不是最后一次尝试,等待一段时间再重试
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = attempt * 1000 + Math.random() * 1000 // 1-2秒的随机延迟
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error(`[Backup] Failed to delete old backup file after ${maxRetries} attempts: ${fileName}`, lastError)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
export async function backup(skipBackupFile: boolean) {
|
export async function backup(skipBackupFile: boolean) {
|
||||||
const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip`
|
const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip`
|
||||||
const fileContnet = await getBackupData()
|
const fileContnet = await getBackupData()
|
||||||
@ -161,17 +213,21 @@ export async function backupToWebdav({
|
|||||||
// 文件已按修改时间降序排序,所以最旧的文件在末尾
|
// 文件已按修改时间降序排序,所以最旧的文件在末尾
|
||||||
const filesToDelete = currentDeviceFiles.slice(webdavMaxBackups)
|
const filesToDelete = currentDeviceFiles.slice(webdavMaxBackups)
|
||||||
|
|
||||||
for (const file of filesToDelete) {
|
Logger.log(`[Backup] Cleaning up ${filesToDelete.length} old backup files`)
|
||||||
try {
|
|
||||||
await window.api.backup.deleteWebdavFile(file.fileName, {
|
// 串行删除文件,避免并发请求导致的问题
|
||||||
webdavHost,
|
for (let i = 0; i < filesToDelete.length; i++) {
|
||||||
webdavUser,
|
const file = filesToDelete[i]
|
||||||
webdavPass,
|
await deleteWebdavFileWithRetry(file.fileName, {
|
||||||
webdavPath
|
webdavHost,
|
||||||
})
|
webdavUser,
|
||||||
Logger.log(`[Backup] Deleted old backup file: ${file.fileName}`)
|
webdavPass,
|
||||||
} catch (error) {
|
webdavPath
|
||||||
Logger.error(`[Backup] Failed to delete old backup file: ${file.fileName}`, error)
|
})
|
||||||
|
|
||||||
|
// 在删除操作之间添加短暂延迟,避免请求过于频繁
|
||||||
|
if (i < filesToDelete.length - 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -242,6 +298,160 @@ export async function restoreFromWebdav(fileName?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function backupToS3({
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoBackupProcess) {
|
||||||
|
showMessage = false
|
||||||
|
}
|
||||||
|
|
||||||
|
isManualBackupRunning = true
|
||||||
|
|
||||||
|
store.dispatch(setS3SyncState({ syncing: true, lastSyncError: null }))
|
||||||
|
|
||||||
|
const s3Config = store.getState().settings.s3
|
||||||
|
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 success = await window.api.backup.backupToS3(backupData, {
|
||||||
|
...s3Config,
|
||||||
|
fileName: finalFileName
|
||||||
|
})
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
store.dispatch(
|
||||||
|
setS3SyncState({
|
||||||
|
lastSyncError: null,
|
||||||
|
syncing: false,
|
||||||
|
lastSyncTime: Date.now()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
notificationService.send({
|
||||||
|
id: uuid(),
|
||||||
|
type: 'success',
|
||||||
|
title: i18n.t('common.success'),
|
||||||
|
message: i18n.t('message.backup.success'),
|
||||||
|
silent: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
source: 'backup'
|
||||||
|
})
|
||||||
|
showMessage && window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
|
||||||
|
|
||||||
|
// 清理旧备份文件
|
||||||
|
if (s3Config.maxBackups > 0) {
|
||||||
|
try {
|
||||||
|
// 获取所有备份文件
|
||||||
|
const files = await window.api.backup.listS3Files(s3Config)
|
||||||
|
|
||||||
|
// 筛选当前设备的备份文件
|
||||||
|
const currentDeviceFiles = files.filter((file) => {
|
||||||
|
return file.fileName.includes(deviceType) && file.fileName.includes(hostname)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果当前设备的备份文件数量超过最大保留数量,删除最旧的文件
|
||||||
|
if (currentDeviceFiles.length > s3Config.maxBackups) {
|
||||||
|
const filesToDelete = currentDeviceFiles.slice(s3Config.maxBackups)
|
||||||
|
|
||||||
|
Logger.log(`[Backup] Cleaning up ${filesToDelete.length} old backup files`)
|
||||||
|
|
||||||
|
for (let i = 0; i < filesToDelete.length; i++) {
|
||||||
|
const file = filesToDelete[i]
|
||||||
|
await deleteS3FileWithRetry(file.fileName, s3Config)
|
||||||
|
|
||||||
|
if (i < filesToDelete.length - 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[Backup] Failed to clean up old backup files:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (autoBackupProcess) {
|
||||||
|
throw new Error(i18n.t('message.backup.failed'))
|
||||||
|
}
|
||||||
|
|
||||||
|
store.dispatch(setS3SyncState({ lastSyncError: 'Backup failed' }))
|
||||||
|
showMessage && window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' })
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (autoBackupProcess) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
notificationService.send({
|
||||||
|
id: uuid(),
|
||||||
|
type: 'error',
|
||||||
|
title: i18n.t('message.backup.failed'),
|
||||||
|
message: error.message,
|
||||||
|
silent: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
source: 'backup'
|
||||||
|
})
|
||||||
|
store.dispatch(setS3SyncState({ lastSyncError: error.message }))
|
||||||
|
console.error('[Backup] backupToS3: Error uploading file to S3:', error)
|
||||||
|
showMessage && window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' })
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
if (!autoBackupProcess) {
|
||||||
|
store.dispatch(
|
||||||
|
setS3SyncState({
|
||||||
|
lastSyncTime: Date.now(),
|
||||||
|
syncing: false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isManualBackupRunning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 S3 恢复
|
||||||
|
export async function restoreFromS3(fileName?: string) {
|
||||||
|
const s3Config = store.getState().settings.s3
|
||||||
|
|
||||||
|
if (!fileName) {
|
||||||
|
const files = await window.api.backup.listS3Files(s3Config)
|
||||||
|
if (files.length > 0) {
|
||||||
|
fileName = files[0].fileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileName) {
|
||||||
|
const restoreData = await window.api.backup.restoreFromS3({
|
||||||
|
...s3Config,
|
||||||
|
fileName
|
||||||
|
})
|
||||||
|
const data = JSON.parse(restoreData)
|
||||||
|
await handleData(data)
|
||||||
|
store.dispatch(
|
||||||
|
setS3SyncState({
|
||||||
|
lastSyncTime: Date.now(),
|
||||||
|
syncing: false,
|
||||||
|
lastSyncError: null
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let autoSyncStarted = false
|
let autoSyncStarted = false
|
||||||
let syncTimeout: NodeJS.Timeout | null = null
|
let syncTimeout: NodeJS.Timeout | null = null
|
||||||
let isAutoBackupRunning = false
|
let isAutoBackupRunning = false
|
||||||
@ -252,9 +462,18 @@ export function startAutoSync(immediate = false) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { webdavAutoSync, webdavHost } = store.getState().settings
|
const settings = store.getState().settings
|
||||||
|
const { webdavAutoSync, webdavHost } = settings
|
||||||
|
const s3Settings = settings.s3
|
||||||
|
|
||||||
if (!webdavAutoSync || !webdavHost) {
|
const s3AutoSync = s3Settings?.autoSync
|
||||||
|
const s3Endpoint = s3Settings?.endpoint
|
||||||
|
|
||||||
|
// 检查WebDAV或S3自动同步配置
|
||||||
|
const hasWebdavConfig = webdavAutoSync && webdavHost
|
||||||
|
const hasS3Config = s3AutoSync && s3Endpoint
|
||||||
|
|
||||||
|
if (!hasWebdavConfig && !hasS3Config) {
|
||||||
Logger.log('[AutoSync] Invalid sync settings, auto sync disabled')
|
Logger.log('[AutoSync] Invalid sync settings, auto sync disabled')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -277,22 +496,28 @@ export function startAutoSync(immediate = false) {
|
|||||||
syncTimeout = null
|
syncTimeout = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const { webdavSyncInterval } = store.getState().settings
|
const settings = store.getState().settings
|
||||||
const { webdavSync } = store.getState().backup
|
const _webdavSyncInterval = settings.webdavSyncInterval
|
||||||
|
const _s3SyncInterval = settings.s3?.syncInterval
|
||||||
|
const { webdavSync, s3Sync } = store.getState().backup
|
||||||
|
|
||||||
if (webdavSyncInterval <= 0) {
|
// 使用当前激活的同步配置
|
||||||
|
const syncInterval = hasWebdavConfig ? _webdavSyncInterval : _s3SyncInterval
|
||||||
|
const lastSyncTime = hasWebdavConfig ? webdavSync?.lastSyncTime : s3Sync?.lastSyncTime
|
||||||
|
|
||||||
|
if (!syncInterval || syncInterval <= 0) {
|
||||||
Logger.log('[AutoSync] Invalid sync interval, auto sync disabled')
|
Logger.log('[AutoSync] Invalid sync interval, auto sync disabled')
|
||||||
stopAutoSync()
|
stopAutoSync()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户指定的自动备份时间间隔(毫秒)
|
// 用户指定的自动备份时间间隔(毫秒)
|
||||||
const requiredInterval = webdavSyncInterval * 60 * 1000
|
const requiredInterval = syncInterval * 60 * 1000
|
||||||
|
|
||||||
let timeUntilNextSync = 1000 //also immediate
|
let timeUntilNextSync = 1000 //also immediate
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'fromLastSyncTime': // 如果存在最后一次同步WebDAV的时间,以它为参考计算下一次同步的时间
|
case 'fromLastSyncTime': // 如果存在最后一次同步的时间,以它为参考计算下一次同步的时间
|
||||||
timeUntilNextSync = Math.max(1000, (webdavSync?.lastSyncTime || 0) + requiredInterval - Date.now())
|
timeUntilNextSync = Math.max(1000, (lastSyncTime || 0) + requiredInterval - Date.now())
|
||||||
break
|
break
|
||||||
case 'fromNow':
|
case 'fromNow':
|
||||||
timeUntilNextSync = requiredInterval
|
timeUntilNextSync = requiredInterval
|
||||||
@ -301,8 +526,9 @@ export function startAutoSync(immediate = false) {
|
|||||||
|
|
||||||
syncTimeout = setTimeout(performAutoBackup, timeUntilNextSync)
|
syncTimeout = setTimeout(performAutoBackup, timeUntilNextSync)
|
||||||
|
|
||||||
|
const backupType = hasWebdavConfig ? 'WebDAV' : 'S3'
|
||||||
Logger.log(
|
Logger.log(
|
||||||
`[AutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor(
|
`[AutoSync] Next ${backupType} sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor(
|
||||||
(timeUntilNextSync / 1000) % 60
|
(timeUntilNextSync / 1000) % 60
|
||||||
)} seconds`
|
)} seconds`
|
||||||
)
|
)
|
||||||
@ -321,17 +547,28 @@ export function startAutoSync(immediate = false) {
|
|||||||
|
|
||||||
while (retryCount < maxRetries) {
|
while (retryCount < maxRetries) {
|
||||||
try {
|
try {
|
||||||
Logger.log(`[AutoSync] Starting auto backup... (attempt ${retryCount + 1}/${maxRetries})`)
|
const backupType = hasWebdavConfig ? 'WebDAV' : 'S3'
|
||||||
|
Logger.log(`[AutoSync] Starting auto ${backupType} backup... (attempt ${retryCount + 1}/${maxRetries})`)
|
||||||
|
|
||||||
await backupToWebdav({ autoBackupProcess: true })
|
if (hasWebdavConfig) {
|
||||||
|
await backupToWebdav({ autoBackupProcess: true })
|
||||||
store.dispatch(
|
store.dispatch(
|
||||||
setWebDAVSyncState({
|
setWebDAVSyncState({
|
||||||
lastSyncError: null,
|
lastSyncError: null,
|
||||||
lastSyncTime: Date.now(),
|
lastSyncTime: Date.now(),
|
||||||
syncing: false
|
syncing: false
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
} else if (hasS3Config) {
|
||||||
|
await backupToS3({ autoBackupProcess: true })
|
||||||
|
store.dispatch(
|
||||||
|
setS3SyncState({
|
||||||
|
lastSyncError: null,
|
||||||
|
lastSyncTime: Date.now(),
|
||||||
|
syncing: false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
isAutoBackupRunning = false
|
isAutoBackupRunning = false
|
||||||
scheduleNextBackup()
|
scheduleNextBackup()
|
||||||
@ -340,20 +577,31 @@ export function startAutoSync(immediate = false) {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
retryCount++
|
retryCount++
|
||||||
if (retryCount === maxRetries) {
|
if (retryCount === maxRetries) {
|
||||||
Logger.error('[AutoSync] Auto backup failed after all retries:', error)
|
const backupType = hasWebdavConfig ? 'WebDAV' : 'S3'
|
||||||
|
Logger.error(`[AutoSync] Auto ${backupType} backup failed after all retries:`, error)
|
||||||
|
|
||||||
store.dispatch(
|
if (hasWebdavConfig) {
|
||||||
setWebDAVSyncState({
|
store.dispatch(
|
||||||
lastSyncError: 'Auto backup failed',
|
setWebDAVSyncState({
|
||||||
lastSyncTime: Date.now(),
|
lastSyncError: 'Auto backup failed',
|
||||||
syncing: false
|
lastSyncTime: Date.now(),
|
||||||
})
|
syncing: false
|
||||||
)
|
})
|
||||||
|
)
|
||||||
|
} else if (hasS3Config) {
|
||||||
|
store.dispatch(
|
||||||
|
setS3SyncState({
|
||||||
|
lastSyncError: 'Auto backup failed',
|
||||||
|
lastSyncTime: Date.now(),
|
||||||
|
syncing: false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
//only show 1 time error modal, and autoback stopped until user click ok
|
//only show 1 time error modal, and autoback stopped until user click ok
|
||||||
await window.modal.error({
|
await window.modal.error({
|
||||||
title: i18n.t('message.backup.failed'),
|
title: i18n.t('message.backup.failed'),
|
||||||
content: `[WebDAV Auto Backup] ${new Date().toLocaleString()} ` + error.message
|
content: `[${backupType} Auto Backup] ${new Date().toLocaleString()} ` + error.message
|
||||||
})
|
})
|
||||||
|
|
||||||
scheduleNextBackup('fromNow')
|
scheduleNextBackup('fromNow')
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export async function checkConnection() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSuccess = await window.api.backup.checkConnection({
|
const isSuccess = await window.api.backup.checkWebdavConnection({
|
||||||
...config,
|
...config,
|
||||||
webdavPath: '/'
|
webdavPath: '/'
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||||
|
|
||||||
export interface WebDAVSyncState {
|
export interface RemoteSyncState {
|
||||||
lastSyncTime: number | null
|
lastSyncTime: number | null
|
||||||
syncing: boolean
|
syncing: boolean
|
||||||
lastSyncError: string | null
|
lastSyncError: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupState {
|
export interface BackupState {
|
||||||
webdavSync: WebDAVSyncState
|
webdavSync: RemoteSyncState
|
||||||
|
s3Sync: RemoteSyncState
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: BackupState = {
|
const initialState: BackupState = {
|
||||||
@ -15,6 +16,11 @@ const initialState: BackupState = {
|
|||||||
lastSyncTime: null,
|
lastSyncTime: null,
|
||||||
syncing: false,
|
syncing: false,
|
||||||
lastSyncError: null
|
lastSyncError: null
|
||||||
|
},
|
||||||
|
s3Sync: {
|
||||||
|
lastSyncTime: null,
|
||||||
|
syncing: false,
|
||||||
|
lastSyncError: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,11 +28,14 @@ const backupSlice = createSlice({
|
|||||||
name: 'backup',
|
name: 'backup',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setWebDAVSyncState: (state, action: PayloadAction<Partial<WebDAVSyncState>>) => {
|
setWebDAVSyncState: (state, action: PayloadAction<Partial<RemoteSyncState>>) => {
|
||||||
state.webdavSync = { ...state.webdavSync, ...action.payload }
|
state.webdavSync = { ...state.webdavSync, ...action.payload }
|
||||||
|
},
|
||||||
|
setS3SyncState: (state, action: PayloadAction<Partial<RemoteSyncState>>) => {
|
||||||
|
state.s3Sync = { ...state.s3Sync, ...action.payload }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { setWebDAVSyncState } = backupSlice.actions
|
export const { setWebDAVSyncState, setS3SyncState } = backupSlice.actions
|
||||||
export default backupSlice.reducer
|
export default backupSlice.reducer
|
||||||
|
|||||||
@ -1726,6 +1726,16 @@ const migrateConfig = {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
'120': (state: RootState) => {
|
||||||
|
try {
|
||||||
|
if (!state.settings.s3) {
|
||||||
|
state.settings.s3 = settingsInitialState.s3
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
} catch (error) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||||
|
|
||||||
import { WebDAVSyncState } from './backup'
|
import { RemoteSyncState } from './backup'
|
||||||
|
|
||||||
export interface NutstoreSyncState extends WebDAVSyncState {}
|
export interface NutstoreSyncState extends RemoteSyncState {}
|
||||||
|
|
||||||
export interface NutstoreState {
|
export interface NutstoreState {
|
||||||
nutstoreToken: string | null
|
nutstoreToken: string | null
|
||||||
@ -42,7 +42,7 @@ const nutstoreSlice = createSlice({
|
|||||||
setNutstoreSyncInterval: (state, action: PayloadAction<number>) => {
|
setNutstoreSyncInterval: (state, action: PayloadAction<number>) => {
|
||||||
state.nutstoreSyncInterval = action.payload
|
state.nutstoreSyncInterval = action.payload
|
||||||
},
|
},
|
||||||
setNutstoreSyncState: (state, action: PayloadAction<Partial<WebDAVSyncState>>) => {
|
setNutstoreSyncState: (state, action: PayloadAction<Partial<RemoteSyncState>>) => {
|
||||||
state.nutstoreSyncState = { ...state.nutstoreSyncState, ...action.payload }
|
state.nutstoreSyncState = { ...state.nutstoreSyncState, ...action.payload }
|
||||||
},
|
},
|
||||||
setNutstoreSkipBackupFile: (state, action: PayloadAction<boolean>) => {
|
setNutstoreSkipBackupFile: (state, action: PayloadAction<boolean>) => {
|
||||||
|
|||||||
@ -8,13 +8,14 @@ import {
|
|||||||
OpenAIServiceTier,
|
OpenAIServiceTier,
|
||||||
OpenAISummaryText,
|
OpenAISummaryText,
|
||||||
PaintingProvider,
|
PaintingProvider,
|
||||||
|
S3Config,
|
||||||
ThemeMode,
|
ThemeMode,
|
||||||
TranslateLanguageVarious
|
TranslateLanguageVarious
|
||||||
} from '@renderer/types'
|
} from '@renderer/types'
|
||||||
import { uuid } from '@renderer/utils'
|
import { uuid } from '@renderer/utils'
|
||||||
import { UpgradeChannel } from '@shared/config/constant'
|
import { UpgradeChannel } from '@shared/config/constant'
|
||||||
|
|
||||||
import { WebDAVSyncState } from './backup'
|
import { RemoteSyncState } from './backup'
|
||||||
|
|
||||||
export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter' | 'Alt+Enter'
|
export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter' | 'Alt+Enter'
|
||||||
|
|
||||||
@ -30,7 +31,7 @@ export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [
|
|||||||
'files'
|
'files'
|
||||||
]
|
]
|
||||||
|
|
||||||
export interface NutstoreSyncRuntime extends WebDAVSyncState {}
|
export interface NutstoreSyncRuntime extends RemoteSyncState {}
|
||||||
|
|
||||||
export type AssistantIconType = 'model' | 'emoji' | 'none'
|
export type AssistantIconType = 'model' | 'emoji' | 'none'
|
||||||
|
|
||||||
@ -189,6 +190,7 @@ export interface SettingsState {
|
|||||||
knowledge: boolean
|
knowledge: boolean
|
||||||
}
|
}
|
||||||
defaultPaintingProvider: PaintingProvider
|
defaultPaintingProvider: PaintingProvider
|
||||||
|
s3: S3Config
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
|
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
|
||||||
@ -336,7 +338,19 @@ export const initialState: SettingsState = {
|
|||||||
backup: false,
|
backup: false,
|
||||||
knowledge: false
|
knowledge: false
|
||||||
},
|
},
|
||||||
defaultPaintingProvider: 'aihubmix'
|
defaultPaintingProvider: 'aihubmix',
|
||||||
|
s3: {
|
||||||
|
endpoint: '',
|
||||||
|
region: '',
|
||||||
|
bucket: '',
|
||||||
|
accessKeyId: '',
|
||||||
|
secretAccessKey: '',
|
||||||
|
root: '',
|
||||||
|
autoSync: false,
|
||||||
|
syncInterval: 0,
|
||||||
|
maxBackups: 0,
|
||||||
|
skipBackupFile: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsSlice = createSlice({
|
const settingsSlice = createSlice({
|
||||||
@ -703,6 +717,12 @@ const settingsSlice = createSlice({
|
|||||||
},
|
},
|
||||||
setDefaultPaintingProvider: (state, action: PayloadAction<PaintingProvider>) => {
|
setDefaultPaintingProvider: (state, action: PayloadAction<PaintingProvider>) => {
|
||||||
state.defaultPaintingProvider = action.payload
|
state.defaultPaintingProvider = action.payload
|
||||||
|
},
|
||||||
|
setS3: (state, action: PayloadAction<S3Config>) => {
|
||||||
|
state.s3 = action.payload
|
||||||
|
},
|
||||||
|
setS3Partial: (state, action: PayloadAction<Partial<S3Config>>) => {
|
||||||
|
state.s3 = { ...state.s3, ...action.payload }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -812,7 +832,9 @@ export const {
|
|||||||
setOpenAISummaryText,
|
setOpenAISummaryText,
|
||||||
setOpenAIServiceTier,
|
setOpenAIServiceTier,
|
||||||
setNotificationSettings,
|
setNotificationSettings,
|
||||||
setDefaultPaintingProvider
|
setDefaultPaintingProvider,
|
||||||
|
setS3,
|
||||||
|
setS3Partial
|
||||||
} = settingsSlice.actions
|
} = settingsSlice.actions
|
||||||
|
|
||||||
export default settingsSlice.reducer
|
export default settingsSlice.reducer
|
||||||
|
|||||||
@ -749,4 +749,19 @@ export interface StoreSyncAction {
|
|||||||
|
|
||||||
export type OpenAISummaryText = 'auto' | 'concise' | 'detailed' | 'off'
|
export type OpenAISummaryText = 'auto' | 'concise' | 'detailed' | 'off'
|
||||||
export type OpenAIServiceTier = 'auto' | 'default' | 'flex'
|
export type OpenAIServiceTier = 'auto' | 'default' | 'flex'
|
||||||
|
|
||||||
|
export type S3Config = {
|
||||||
|
endpoint: string
|
||||||
|
region: string
|
||||||
|
bucket: string
|
||||||
|
accessKeyId: string
|
||||||
|
secretAccessKey: string
|
||||||
|
root?: string
|
||||||
|
fileName?: string
|
||||||
|
skipBackupFile: boolean
|
||||||
|
autoSync: boolean
|
||||||
|
syncInterval: number
|
||||||
|
maxBackups: number
|
||||||
|
}
|
||||||
|
|
||||||
export type { Message } from './newMessage'
|
export type { Message } from './newMessage'
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user