diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 30cc9b4315..6ef7f834b9 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2916,7 +2916,7 @@ "singleFileName": { "title": "自定义文件名(可选)", "placeholder": "如:cherry-studio...zip", - "help": "• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip\n• 支持的变量:{hostname} - 主机名,{device} - 设备类型\n• 不支持的字符:<>:\"/\\|?*\n• 最大长度:250个字符", + "help": "留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip\n支持的变量:{hostname} - 主机名,{device} - 设备类型\n不支持的字符:<>:\"/\\|?*\n最大长度:250个字符", "invalid_chars": "文件名包含无效字符", "reserved": "文件名是系统保留名称", "too_long": "文件名过长" diff --git a/src/renderer/src/pages/settings/DataSettings/LocalBackupSettings.tsx b/src/renderer/src/pages/settings/DataSettings/LocalBackupSettings.tsx index cb8c668e8a..5a74883c93 100644 --- a/src/renderer/src/pages/settings/DataSettings/LocalBackupSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/LocalBackupSettings.tsx @@ -69,6 +69,11 @@ const LocalBackupSettings: React.FC = () => { const { localBackupSync } = useAppSelector((state) => state.backup) + // 同步 maxBackups 状态 + useEffect(() => { + setMaxBackups(localBackupMaxBackupsSetting) + }, [localBackupMaxBackupsSetting]) + const onSyncIntervalChange = (value: number) => { setSyncInterval(value) dispatch(_setLocalBackupSyncInterval(value)) @@ -273,37 +278,6 @@ const LocalBackupSettings: React.FC = () => { {t('settings.data.local.title')} - {/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */} - - - {t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'} - - 0 && maxBackups === 1)} - /> - - - - {t('settings.data.backup.singleFileOverwrite.help') || - '当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。'} - - - - {t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'} - ..zip' - } - value={localSingleFileName} - onChange={(e) => onSingleFileNameChange(e.target.value)} - onBlur={onSingleFileNameBlur} - style={{ width: 300 }} - disabled={!localSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)} - /> - - {t('settings.data.local.directory.label')} @@ -383,6 +357,58 @@ const LocalBackupSettings: React.FC = () => { {t('settings.data.backup.skip_file_data_help')} + {/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */} + + + + {t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'} + + 0 && maxBackups === 1)} + /> + + + + {t('settings.data.backup.singleFileOverwrite.help') || ( +
+

当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。

+

+ 推荐场景:只需要保留最新备份,节省本地存储空间 +

+
+ )} +
+
+ + + {t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'} + ..zip' + } + value={localSingleFileName} + onChange={(e) => onSingleFileNameChange(e.target.value)} + onBlur={onSingleFileNameBlur} + style={{ width: 300 }} + disabled={!localSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)} + /> + + + + {t('settings.data.backup.singleFileName.help') || ( +
+

• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip

+

+ • 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型 +

+

• 不支持的字符:{'<>:"/\\|?*'}

+

• 最大长度:250个字符

+
+ )} +
+
{localBackupSync && syncInterval > 0 && ( <> diff --git a/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx b/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx index a5d2a7f77b..f539df9294 100644 --- a/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx @@ -20,6 +20,8 @@ import { setNutstoreAutoSync, setNutstoreMaxBackups, setNutstorePath, + setNutstoreSingleFileName, + setNutstoreSingleFileOverwrite, setNutstoreSkipBackupFile, setNutstoreSyncInterval, setNutstoreToken @@ -44,7 +46,9 @@ const NutstoreSettings: FC = () => { nutstoreAutoSync, nutstoreSyncState, nutstoreSkipBackupFile, - nutstoreMaxBackups + nutstoreMaxBackups, + nutstoreSingleFileOverwrite, + nutstoreSingleFileName } = useAppSelector((state) => state.nutstore) const dispatch = useAppDispatch() @@ -55,12 +59,20 @@ const NutstoreSettings: FC = () => { const [checkConnectionLoading, setCheckConnectionLoading] = useState(false) const [nsConnected, setNsConnected] = useState(false) const [syncInterval, setSyncInterval] = useState(nutstoreSyncInterval) + const [maxBackups, setMaxBackups] = useState(nutstoreMaxBackups) const [nutSkipBackupFile, setNutSkipBackupFile] = useState(nutstoreSkipBackupFile) + const [nutSingleFileOverwrite, setNutSingleFileOverwrite] = useState(nutstoreSingleFileOverwrite ?? false) + const [nutSingleFileName, setNutSingleFileName] = useState(nutstoreSingleFileName ?? '') const [backupManagerVisible, setBackupManagerVisible] = useState(false) const nutstoreSSOHandler = useNutstoreSSO() const { setTimeoutTimer } = useTimer() + // 同步 maxBackups 状态 + useEffect(() => { + setMaxBackups(nutstoreMaxBackups) + }, [nutstoreMaxBackups]) + const handleClickNutstoreSSO = useCallback(async () => { const ssoUrl = await window.api.nutstore.getSSOUrl() window.open(ssoUrl, '_blank') @@ -142,9 +154,72 @@ const NutstoreSettings: FC = () => { } const onMaxBackupsChange = (value: number) => { + setMaxBackups(value) dispatch(setNutstoreMaxBackups(value)) } + const onSingleFileOverwriteChange = (value: boolean) => { + // Only show confirmation when enabling + if (value && !nutSingleFileOverwrite) { + window.modal.confirm({ + title: t('settings.data.backup.singleFileOverwrite.confirm.title') || '启用覆盖式备份', + content: ( +
+

{t('settings.data.backup.singleFileOverwrite.confirm.content1') || '启用后,自动备份将:'}

+
    +
  • {t('settings.data.backup.singleFileOverwrite.confirm.item1') || '使用固定文件名,不再添加时间戳'}
  • +
  • {t('settings.data.backup.singleFileOverwrite.confirm.item2') || '每次备份都会覆盖同名文件'}
  • +
  • {t('settings.data.backup.singleFileOverwrite.confirm.item3') || '仅保留最新的一个备份文件'}
  • +
+

+ {t('settings.data.backup.singleFileOverwrite.confirm.note') || + '注意:此设置仅在自动备份且保留份数为1时生效'} +

+
+ ), + okText: t('common.confirm') || '确认', + cancelText: t('common.cancel') || '取消', + onOk: () => { + setNutSingleFileOverwrite(value) + dispatch(setNutstoreSingleFileOverwrite(value)) + } + }) + } else { + setNutSingleFileOverwrite(value) + dispatch(setNutstoreSingleFileOverwrite(value)) + } + } + + const onSingleFileNameChange = (value: string) => { + setNutSingleFileName(value) + } + + const onSingleFileNameBlur = () => { + const trimmed = nutSingleFileName.trim() + // Validate filename + if (trimmed) { + // Check for invalid characters + const invalidChars = /[<>:"/\\|?*]/ + if (invalidChars.test(trimmed)) { + window.toast.error(t('settings.data.backup.singleFileName.invalid_chars') || '文件名包含无效字符') + return + } + // Check for reserved names (Windows) + const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i + const nameWithoutExt = trimmed.replace(/\.zip$/i, '') + if (reservedNames.test(nameWithoutExt)) { + window.toast.error(t('settings.data.backup.singleFileName.reserved') || '文件名是系统保留名称') + return + } + // Check length + if (trimmed.length > 250) { + window.toast.error(t('settings.data.backup.singleFileName.too_long') || '文件名过长') + return + } + } + dispatch(setNutstoreSingleFileName(trimmed)) + } + const handleClickPathChange = async () => { if (!nutstoreToken) { return @@ -336,6 +411,60 @@ const NutstoreSettings: FC = () => { {t('settings.data.backup.skip_file_data_help')} + {/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */} + + + + {t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'} + + 0 && maxBackups === 1)} + /> + + + + {t('settings.data.backup.singleFileOverwrite.help') || ( +
+

当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。

+

+ 推荐场景:只需要保留最新备份,节省坚果云存储空间 +

+
+ )} +
+
+ + + + {t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'} + + ..zip' + } + value={nutSingleFileName} + onChange={(e) => onSingleFileNameChange(e.target.value)} + onBlur={onSingleFileNameBlur} + style={{ width: 300 }} + disabled={!nutSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)} + /> + + + + {t('settings.data.backup.singleFileName.help') || ( +
+

• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip

+

+ • 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型 +

+

• 不支持的字符:{'<>:"/\\|?*'}

+

• 最大长度:250个字符

+
+ )} +
+
)} <> diff --git a/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx b/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx index 38bc0b05c2..354ddfc7d5 100644 --- a/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx @@ -13,7 +13,7 @@ import { setS3Partial } from '@renderer/store/settings' import { S3Config } from '@renderer/types' import { Button, Input, Switch, Tooltip } from 'antd' import dayjs from 'dayjs' -import { FC, useState } from 'react' +import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..' @@ -56,6 +56,11 @@ const S3Settings: FC = () => { const { s3Sync } = useAppSelector((state) => state.backup) + // 同步 maxBackups 状态 + useEffect(() => { + setMaxBackups(s3MaxBackupsInit) + }, [s3MaxBackupsInit]) + const onSyncIntervalChange = (value: number) => { setSyncInterval(value) dispatch(setS3Partial({ syncInterval: value, autoSync: value !== 0 })) @@ -192,37 +197,6 @@ const S3Settings: FC = () => { {t('settings.data.s3.title.help')} - {/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */} - - - {t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'} - - 0 && maxBackups === 1)} - /> - - - - {t('settings.data.backup.singleFileOverwrite.help') || - '当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。S3 会直接覆盖同键对象。'} - - - - {t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'} - ..zip' - } - value={singleFileName} - onChange={(e) => onSingleFileNameChange(e.target.value)} - onBlur={onSingleFileNameBlur} - style={{ width: 300 }} - disabled={!singleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)} - /> - - {t('settings.data.s3.endpoint.label')} { {t('settings.data.s3.skipBackupFile.help')} + {/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */} + + + + {t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'} + + 0 && maxBackups === 1)} + /> + + + + {t('settings.data.backup.singleFileOverwrite.help') || ( +
+

当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。

+

+ 推荐场景:只需要保留最新备份,节省存储空间(S3会直接覆盖同键对象) +

+
+ )} +
+
+ + + {t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'} + ..zip' + } + value={singleFileName} + onChange={(e) => onSingleFileNameChange(e.target.value)} + onBlur={onSingleFileNameBlur} + style={{ width: 300 }} + disabled={!singleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)} + /> + + + + {t('settings.data.backup.singleFileName.help') || ( +
+

• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip

+

+ • 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型 +

+

• 不支持的字符:{'<>:"/\\|?*'}

+

• 最大长度:250个字符

+
+ )} +
+
{syncInterval > 0 && ( <> diff --git a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx index 6e62662a36..86d02fe1a0 100644 --- a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx @@ -22,7 +22,7 @@ import { } from '@renderer/store/settings' import { Button, Input, Switch, Tooltip } from 'antd' import dayjs from 'dayjs' -import { FC, useState } from 'react' +import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..' @@ -63,6 +63,11 @@ const WebDavSettings: FC = () => { const { webdavSync } = useAppSelector((state) => state.backup) + // 同步 maxBackups 状态 + useEffect(() => { + setMaxBackups(webDAVMaxBackups) + }, [webDAVMaxBackups]) + // 把之前备份的文件定时上传到 webdav,首先先配置 webdav 的 host, port, user, pass, path const onSyncIntervalChange = (value: number) => { @@ -193,57 +198,6 @@ const WebDavSettings: FC = () => { {t('settings.data.webdav.title')} - {/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */} - - - {t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'} - - 0 && maxBackups === 1)} - /> - - - - {t('settings.data.backup.singleFileOverwrite.help') || ( -
-

当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。

-

- 推荐场景:只需要保留最新备份,节省存储空间 -

-
- )} -
-
- - {t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'} - ..zip' - } - value={webdavSingleFileName} - onChange={(e) => onSingleFileNameChange(e.target.value)} - onBlur={onSingleFileNameBlur} - style={{ width: 300 }} - disabled={!webdavSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)} - /> - - - - {t('settings.data.backup.singleFileName.help') || ( -
-

• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip

-

- • 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型 -

-

• 不支持的字符:{'<>:"/\\|?*'}

-

• 最大长度:250个字符

-
- )} -
-
- {t('settings.data.webdav.host.label')} { {t('settings.data.webdav.disableStream.help')} + {/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */} + + + + {t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'} + + 0 && maxBackups === 1)} + /> + + + + {t('settings.data.backup.singleFileOverwrite.help') || ( +
+

当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。

+

+ 推荐场景:只需要保留最新备份,节省存储空间 +

+
+ )} +
+
+ + + {t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'} + ..zip' + } + value={webdavSingleFileName} + onChange={(e) => onSingleFileNameChange(e.target.value)} + onBlur={onSingleFileNameBlur} + style={{ width: 300 }} + disabled={!webdavSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)} + /> + + + + {t('settings.data.backup.singleFileName.help') || ( +
+

• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip

+

+ • 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型 +

+

• 不支持的字符:{'<>:"/\\|?*'}

+

• 最大长度:250个字符

+
+ )} +
+
{webdavSync && syncInterval > 0 && ( <> diff --git a/src/renderer/src/services/NutstoreService.ts b/src/renderer/src/services/NutstoreService.ts index 17dee74708..ab28d9ab75 100644 --- a/src/renderer/src/services/NutstoreService.ts +++ b/src/renderer/src/services/NutstoreService.ts @@ -7,6 +7,7 @@ import { NUTSTORE_HOST } from '@shared/config/nutstore' import dayjs from 'dayjs' import { type CreateDirectoryOptions } from 'webdav' +import { shouldSkipCleanup, validateAndSanitizeFilename } from '../utils/backupUtils' import { getBackupData, handleData } from './BackupService' const logger = loggerService.withContext('NutstoreService') @@ -109,10 +110,12 @@ async function cleanupOldBackups(webdavConfig: WebDavConfig, maxBackups: number) export async function backupToNutstore({ showMessage = false, - customFileName = '' + customFileName = '', + isAutoBackup = false }: { showMessage?: boolean customFileName?: string + isAutoBackup?: boolean } = {}) { const nutstoreToken = getNutstoreToken() if (!nutstoreToken) { @@ -135,21 +138,37 @@ export async function backupToNutstore({ } catch (error) { logger.error('[backupToNutstore] Failed to get device type:', error as Error) } - const timestamp = dayjs().format('YYYYMMDDHHmmss') - const backupFileName = customFileName || `cherry-studio.${timestamp}.${deviceType}.zip` - const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip` + + const backupData = await getBackupData() + const skipBackupFile = store.getState().nutstore.nutstoreSkipBackupFile + const maxBackups = store.getState().nutstore.nutstoreMaxBackups + const singleFileOverwrite = store.getState().nutstore.nutstoreSingleFileOverwrite + const singleFileName = store.getState().nutstore.nutstoreSingleFileName + + // Handle filename generation + let finalFileName: string + if (isAutoBackup && singleFileOverwrite && maxBackups === 1) { + // Use overwrite logic for auto backup when single file overwrite is enabled + const hostname = await window.api.system.getHostname() + const name = await validateAndSanitizeFilename(singleFileName, hostname, deviceType) + finalFileName = name + } else { + // Use timestamped logic for manual backup or when overwrite is disabled + const timestamp = dayjs().format('YYYYMMDDHHmmss') + const name = customFileName || `cherry-studio.${timestamp}.${deviceType}.zip` + finalFileName = name.endsWith('.zip') ? name : `${name}.zip` + } isManualBackupRunning = true store.dispatch(setNutstoreSyncState({ syncing: true, lastSyncError: null })) - const backupData = await getBackupData() - const skipBackupFile = store.getState().nutstore.nutstoreSkipBackupFile - const maxBackups = store.getState().nutstore.nutstoreMaxBackups - try { - // 先清理旧备份 - await cleanupOldBackups(config, maxBackups) + // Skip cleanup for single file overwrite mode when maxBackups is 1 + if (!shouldSkipCleanup(maxBackups, singleFileOverwrite)) { + // 先清理旧备份 + await cleanupOldBackups(config, maxBackups) + } const isSuccess = await window.api.backup.backupToWebdav(backupData, { ...config, @@ -264,7 +283,7 @@ export async function startNutstoreAutoSync() { isAutoBackupRunning = true try { logger.verbose('[Nutstore AutoSync] Starting auto backup...') - await backupToNutstore({ showMessage: false }) + await backupToNutstore({ showMessage: false, isAutoBackup: true }) } catch (error) { logger.error('[Nutstore AutoSync] Auto backup failed:', error as Error) } finally { diff --git a/src/renderer/src/store/nutstore.ts b/src/renderer/src/store/nutstore.ts index e2a05f021e..558282a745 100644 --- a/src/renderer/src/store/nutstore.ts +++ b/src/renderer/src/store/nutstore.ts @@ -12,6 +12,8 @@ export interface NutstoreState { nutstoreSyncState: NutstoreSyncState nutstoreSkipBackupFile: boolean nutstoreMaxBackups: number + nutstoreSingleFileOverwrite: boolean + nutstoreSingleFileName: string } const initialState: NutstoreState = { @@ -25,7 +27,9 @@ const initialState: NutstoreState = { lastSyncError: null }, nutstoreSkipBackupFile: false, - nutstoreMaxBackups: 0 + nutstoreMaxBackups: 0, + nutstoreSingleFileOverwrite: false, + nutstoreSingleFileName: '' } const nutstoreSlice = createSlice({ @@ -52,6 +56,12 @@ const nutstoreSlice = createSlice({ }, setNutstoreMaxBackups: (state, action: PayloadAction) => { state.nutstoreMaxBackups = action.payload + }, + setNutstoreSingleFileOverwrite: (state, action: PayloadAction) => { + state.nutstoreSingleFileOverwrite = action.payload + }, + setNutstoreSingleFileName: (state, action: PayloadAction) => { + state.nutstoreSingleFileName = action.payload } } }) @@ -63,7 +73,9 @@ export const { setNutstoreSyncInterval, setNutstoreSyncState, setNutstoreSkipBackupFile, - setNutstoreMaxBackups + setNutstoreMaxBackups, + setNutstoreSingleFileOverwrite, + setNutstoreSingleFileName } = nutstoreSlice.actions export default nutstoreSlice.reducer