mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-02 02:09:03 +08:00
feat(settings): add single-file overwrite options and sync maxBackups
This commit is contained in:
parent
f846a27418
commit
980f20fcca
@ -2916,7 +2916,7 @@
|
||||
"singleFileName": {
|
||||
"title": "自定义文件名(可选)",
|
||||
"placeholder": "如:cherry-studio.<hostname>.<device>.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": "文件名过长"
|
||||
|
||||
@ -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 = () => {
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.data.local.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
{/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */}
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'}
|
||||
</SettingRowTitle>
|
||||
<Switch
|
||||
checked={localSingleFileOverwrite}
|
||||
onChange={onSingleFileOverwriteChange}
|
||||
disabled={!(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileOverwrite.help') ||
|
||||
'当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。'}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={
|
||||
t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio.<hostname>.<device>.zip'
|
||||
}
|
||||
value={localSingleFileName}
|
||||
onChange={(e) => onSingleFileNameChange(e.target.value)}
|
||||
onBlur={onSingleFileNameBlur}
|
||||
style={{ width: 300 }}
|
||||
disabled={!localSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.local.directory.label')}</SettingRowTitle>
|
||||
<HStack gap="5px">
|
||||
@ -383,6 +357,58 @@ const LocalBackupSettings: React.FC = () => {
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
{/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'}
|
||||
</SettingRowTitle>
|
||||
<Switch
|
||||
checked={localSingleFileOverwrite}
|
||||
onChange={onSingleFileOverwriteChange}
|
||||
disabled={!(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileOverwrite.help') || (
|
||||
<div>
|
||||
<p>当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。</p>
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
推荐场景:只需要保留最新备份,节省本地存储空间
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={
|
||||
t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio.<hostname>.<device>.zip'
|
||||
}
|
||||
value={localSingleFileName}
|
||||
onChange={(e) => onSingleFileNameChange(e.target.value)}
|
||||
onBlur={onSingleFileNameBlur}
|
||||
style={{ width: 300 }}
|
||||
disabled={!localSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileName.help') || (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<p>• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip</p>
|
||||
<p>
|
||||
• 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型
|
||||
</p>
|
||||
<p>• 不支持的字符:{'<>:"/\\|?*'}</p>
|
||||
<p>• 最大长度:250个字符</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
{localBackupSync && syncInterval > 0 && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
|
||||
@ -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<boolean>(false)
|
||||
const [syncInterval, setSyncInterval] = useState<number>(nutstoreSyncInterval)
|
||||
const [maxBackups, setMaxBackups] = useState<number>(nutstoreMaxBackups)
|
||||
const [nutSkipBackupFile, setNutSkipBackupFile] = useState<boolean>(nutstoreSkipBackupFile)
|
||||
const [nutSingleFileOverwrite, setNutSingleFileOverwrite] = useState<boolean>(nutstoreSingleFileOverwrite ?? false)
|
||||
const [nutSingleFileName, setNutSingleFileName] = useState<string>(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: (
|
||||
<div>
|
||||
<p>{t('settings.data.backup.singleFileOverwrite.confirm.content1') || '启用后,自动备份将:'}</p>
|
||||
<ul style={{ marginLeft: 20, marginTop: 10 }}>
|
||||
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item1') || '使用固定文件名,不再添加时间戳'}</li>
|
||||
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item2') || '每次备份都会覆盖同名文件'}</li>
|
||||
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item3') || '仅保留最新的一个备份文件'}</li>
|
||||
</ul>
|
||||
<p style={{ marginTop: 10, color: 'var(--text-secondary)' }}>
|
||||
{t('settings.data.backup.singleFileOverwrite.confirm.note') ||
|
||||
'注意:此设置仅在自动备份且保留份数为1时生效'}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
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 = () => {
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
{/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'}
|
||||
</SettingRowTitle>
|
||||
<Switch
|
||||
checked={nutSingleFileOverwrite}
|
||||
onChange={onSingleFileOverwriteChange}
|
||||
disabled={!(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileOverwrite.help') || (
|
||||
<div>
|
||||
<p>当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。</p>
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
推荐场景:只需要保留最新备份,节省坚果云存储空间
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'}
|
||||
</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={
|
||||
t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio.<hostname>.<device>.zip'
|
||||
}
|
||||
value={nutSingleFileName}
|
||||
onChange={(e) => onSingleFileNameChange(e.target.value)}
|
||||
onBlur={onSingleFileNameBlur}
|
||||
style={{ width: 300 }}
|
||||
disabled={!nutSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileName.help') || (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<p>• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip</p>
|
||||
<p>
|
||||
• 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型
|
||||
</p>
|
||||
<p>• 不支持的字符:{'<>:"/\\|?*'}</p>
|
||||
<p>• 最大长度:250个字符</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
|
||||
@ -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 = () => {
|
||||
</SettingTitle>
|
||||
<SettingHelpText>{t('settings.data.s3.title.help')}</SettingHelpText>
|
||||
<SettingDivider />
|
||||
{/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */}
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'}
|
||||
</SettingRowTitle>
|
||||
<Switch
|
||||
checked={singleFileOverwrite}
|
||||
onChange={onSingleFileOverwriteChange}
|
||||
disabled={!(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileOverwrite.help') ||
|
||||
'当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。S3 会直接覆盖同键对象。'}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={
|
||||
t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio.<hostname>.<device>.zip'
|
||||
}
|
||||
value={singleFileName}
|
||||
onChange={(e) => onSingleFileNameChange(e.target.value)}
|
||||
onBlur={onSingleFileNameBlur}
|
||||
style={{ width: 300 }}
|
||||
disabled={!singleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.s3.endpoint.label')}</SettingRowTitle>
|
||||
<Input
|
||||
@ -357,6 +331,58 @@ const S3Settings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.s3.skipBackupFile.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
{/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'}
|
||||
</SettingRowTitle>
|
||||
<Switch
|
||||
checked={singleFileOverwrite}
|
||||
onChange={onSingleFileOverwriteChange}
|
||||
disabled={!(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileOverwrite.help') || (
|
||||
<div>
|
||||
<p>当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。</p>
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
推荐场景:只需要保留最新备份,节省存储空间(S3会直接覆盖同键对象)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={
|
||||
t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio.<hostname>.<device>.zip'
|
||||
}
|
||||
value={singleFileName}
|
||||
onChange={(e) => onSingleFileNameChange(e.target.value)}
|
||||
onBlur={onSingleFileNameBlur}
|
||||
style={{ width: 300 }}
|
||||
disabled={!singleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileName.help') || (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<p>• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip</p>
|
||||
<p>
|
||||
• 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型
|
||||
</p>
|
||||
<p>• 不支持的字符:{'<>:"/\\|?*'}</p>
|
||||
<p>• 最大长度:250个字符</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
{syncInterval > 0 && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
|
||||
@ -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 = () => {
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.data.webdav.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
{/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */}
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'}
|
||||
</SettingRowTitle>
|
||||
<Switch
|
||||
checked={webdavSingleFileOverwrite}
|
||||
onChange={onSingleFileOverwriteChange}
|
||||
disabled={!(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileOverwrite.help') || (
|
||||
<div>
|
||||
<p>当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。</p>
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
推荐场景:只需要保留最新备份,节省存储空间
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={
|
||||
t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio.<hostname>.<device>.zip'
|
||||
}
|
||||
value={webdavSingleFileName}
|
||||
onChange={(e) => onSingleFileNameChange(e.target.value)}
|
||||
onBlur={onSingleFileNameBlur}
|
||||
style={{ width: 300 }}
|
||||
disabled={!webdavSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileName.help') || (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<p>• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip</p>
|
||||
<p>
|
||||
• 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型
|
||||
</p>
|
||||
<p>• 不支持的字符:{'<>:"/\\|?*'}</p>
|
||||
<p>• 最大长度:250个字符</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.webdav.host.label')}</SettingRowTitle>
|
||||
<Input
|
||||
@ -357,6 +311,58 @@ const WebDavSettings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.webdav.disableStream.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
{/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'}
|
||||
</SettingRowTitle>
|
||||
<Switch
|
||||
checked={webdavSingleFileOverwrite}
|
||||
onChange={onSingleFileOverwriteChange}
|
||||
disabled={!(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileOverwrite.help') || (
|
||||
<div>
|
||||
<p>当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。</p>
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
推荐场景:只需要保留最新备份,节省存储空间
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={
|
||||
t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio.<hostname>.<device>.zip'
|
||||
}
|
||||
value={webdavSingleFileName}
|
||||
onChange={(e) => onSingleFileNameChange(e.target.value)}
|
||||
onBlur={onSingleFileNameBlur}
|
||||
style={{ width: 300 }}
|
||||
disabled={!webdavSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileName.help') || (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<p>• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip</p>
|
||||
<p>
|
||||
• 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型
|
||||
</p>
|
||||
<p>• 不支持的字符:{'<>:"/\\|?*'}</p>
|
||||
<p>• 最大长度:250个字符</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
{webdavSync && syncInterval > 0 && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<number>) => {
|
||||
state.nutstoreMaxBackups = action.payload
|
||||
},
|
||||
setNutstoreSingleFileOverwrite: (state, action: PayloadAction<boolean>) => {
|
||||
state.nutstoreSingleFileOverwrite = action.payload
|
||||
},
|
||||
setNutstoreSingleFileName: (state, action: PayloadAction<string>) => {
|
||||
state.nutstoreSingleFileName = action.payload
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -63,7 +73,9 @@ export const {
|
||||
setNutstoreSyncInterval,
|
||||
setNutstoreSyncState,
|
||||
setNutstoreSkipBackupFile,
|
||||
setNutstoreMaxBackups
|
||||
setNutstoreMaxBackups,
|
||||
setNutstoreSingleFileOverwrite,
|
||||
setNutstoreSingleFileName
|
||||
} = nutstoreSlice.actions
|
||||
|
||||
export default nutstoreSlice.reducer
|
||||
|
||||
Loading…
Reference in New Issue
Block a user