mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-06 21:35:52 +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": {
|
"singleFileName": {
|
||||||
"title": "自定义文件名(可选)",
|
"title": "自定义文件名(可选)",
|
||||||
"placeholder": "如:cherry-studio.<hostname>.<device>.zip",
|
"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": "文件名包含无效字符",
|
"invalid_chars": "文件名包含无效字符",
|
||||||
"reserved": "文件名是系统保留名称",
|
"reserved": "文件名是系统保留名称",
|
||||||
"too_long": "文件名过长"
|
"too_long": "文件名过长"
|
||||||
|
|||||||
@ -69,6 +69,11 @@ const LocalBackupSettings: React.FC = () => {
|
|||||||
|
|
||||||
const { localBackupSync } = useAppSelector((state) => state.backup)
|
const { localBackupSync } = useAppSelector((state) => state.backup)
|
||||||
|
|
||||||
|
// 同步 maxBackups 状态
|
||||||
|
useEffect(() => {
|
||||||
|
setMaxBackups(localBackupMaxBackupsSetting)
|
||||||
|
}, [localBackupMaxBackupsSetting])
|
||||||
|
|
||||||
const onSyncIntervalChange = (value: number) => {
|
const onSyncIntervalChange = (value: number) => {
|
||||||
setSyncInterval(value)
|
setSyncInterval(value)
|
||||||
dispatch(_setLocalBackupSyncInterval(value))
|
dispatch(_setLocalBackupSyncInterval(value))
|
||||||
@ -273,37 +278,6 @@ const LocalBackupSettings: React.FC = () => {
|
|||||||
<SettingGroup theme={theme}>
|
<SettingGroup theme={theme}>
|
||||||
<SettingTitle>{t('settings.data.local.title')}</SettingTitle>
|
<SettingTitle>{t('settings.data.local.title')}</SettingTitle>
|
||||||
<SettingDivider />
|
<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>
|
<SettingRow>
|
||||||
<SettingRowTitle>{t('settings.data.local.directory.label')}</SettingRowTitle>
|
<SettingRowTitle>{t('settings.data.local.directory.label')}</SettingRowTitle>
|
||||||
<HStack gap="5px">
|
<HStack gap="5px">
|
||||||
@ -383,6 +357,58 @@ const LocalBackupSettings: React.FC = () => {
|
|||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
||||||
</SettingRow>
|
</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 && (
|
{localBackupSync && syncInterval > 0 && (
|
||||||
<>
|
<>
|
||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
|
|||||||
@ -20,6 +20,8 @@ import {
|
|||||||
setNutstoreAutoSync,
|
setNutstoreAutoSync,
|
||||||
setNutstoreMaxBackups,
|
setNutstoreMaxBackups,
|
||||||
setNutstorePath,
|
setNutstorePath,
|
||||||
|
setNutstoreSingleFileName,
|
||||||
|
setNutstoreSingleFileOverwrite,
|
||||||
setNutstoreSkipBackupFile,
|
setNutstoreSkipBackupFile,
|
||||||
setNutstoreSyncInterval,
|
setNutstoreSyncInterval,
|
||||||
setNutstoreToken
|
setNutstoreToken
|
||||||
@ -44,7 +46,9 @@ const NutstoreSettings: FC = () => {
|
|||||||
nutstoreAutoSync,
|
nutstoreAutoSync,
|
||||||
nutstoreSyncState,
|
nutstoreSyncState,
|
||||||
nutstoreSkipBackupFile,
|
nutstoreSkipBackupFile,
|
||||||
nutstoreMaxBackups
|
nutstoreMaxBackups,
|
||||||
|
nutstoreSingleFileOverwrite,
|
||||||
|
nutstoreSingleFileName
|
||||||
} = useAppSelector((state) => state.nutstore)
|
} = useAppSelector((state) => state.nutstore)
|
||||||
|
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
@ -55,12 +59,20 @@ const NutstoreSettings: FC = () => {
|
|||||||
const [checkConnectionLoading, setCheckConnectionLoading] = useState(false)
|
const [checkConnectionLoading, setCheckConnectionLoading] = useState(false)
|
||||||
const [nsConnected, setNsConnected] = useState<boolean>(false)
|
const [nsConnected, setNsConnected] = useState<boolean>(false)
|
||||||
const [syncInterval, setSyncInterval] = useState<number>(nutstoreSyncInterval)
|
const [syncInterval, setSyncInterval] = useState<number>(nutstoreSyncInterval)
|
||||||
|
const [maxBackups, setMaxBackups] = useState<number>(nutstoreMaxBackups)
|
||||||
const [nutSkipBackupFile, setNutSkipBackupFile] = useState<boolean>(nutstoreSkipBackupFile)
|
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 [backupManagerVisible, setBackupManagerVisible] = useState(false)
|
||||||
|
|
||||||
const nutstoreSSOHandler = useNutstoreSSO()
|
const nutstoreSSOHandler = useNutstoreSSO()
|
||||||
const { setTimeoutTimer } = useTimer()
|
const { setTimeoutTimer } = useTimer()
|
||||||
|
|
||||||
|
// 同步 maxBackups 状态
|
||||||
|
useEffect(() => {
|
||||||
|
setMaxBackups(nutstoreMaxBackups)
|
||||||
|
}, [nutstoreMaxBackups])
|
||||||
|
|
||||||
const handleClickNutstoreSSO = useCallback(async () => {
|
const handleClickNutstoreSSO = useCallback(async () => {
|
||||||
const ssoUrl = await window.api.nutstore.getSSOUrl()
|
const ssoUrl = await window.api.nutstore.getSSOUrl()
|
||||||
window.open(ssoUrl, '_blank')
|
window.open(ssoUrl, '_blank')
|
||||||
@ -142,9 +154,72 @@ const NutstoreSettings: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onMaxBackupsChange = (value: number) => {
|
const onMaxBackupsChange = (value: number) => {
|
||||||
|
setMaxBackups(value)
|
||||||
dispatch(setNutstoreMaxBackups(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 () => {
|
const handleClickPathChange = async () => {
|
||||||
if (!nutstoreToken) {
|
if (!nutstoreToken) {
|
||||||
return
|
return
|
||||||
@ -336,6 +411,60 @@ const NutstoreSettings: FC = () => {
|
|||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
||||||
</SettingRow>
|
</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 { S3Config } from '@renderer/types'
|
||||||
import { Button, Input, Switch, Tooltip } from 'antd'
|
import { Button, Input, Switch, Tooltip } from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { FC, useState } from 'react'
|
import { FC, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||||
@ -56,6 +56,11 @@ const S3Settings: FC = () => {
|
|||||||
|
|
||||||
const { s3Sync } = useAppSelector((state) => state.backup)
|
const { s3Sync } = useAppSelector((state) => state.backup)
|
||||||
|
|
||||||
|
// 同步 maxBackups 状态
|
||||||
|
useEffect(() => {
|
||||||
|
setMaxBackups(s3MaxBackupsInit)
|
||||||
|
}, [s3MaxBackupsInit])
|
||||||
|
|
||||||
const onSyncIntervalChange = (value: number) => {
|
const onSyncIntervalChange = (value: number) => {
|
||||||
setSyncInterval(value)
|
setSyncInterval(value)
|
||||||
dispatch(setS3Partial({ syncInterval: value, autoSync: value !== 0 }))
|
dispatch(setS3Partial({ syncInterval: value, autoSync: value !== 0 }))
|
||||||
@ -192,37 +197,6 @@ const S3Settings: FC = () => {
|
|||||||
</SettingTitle>
|
</SettingTitle>
|
||||||
<SettingHelpText>{t('settings.data.s3.title.help')}</SettingHelpText>
|
<SettingHelpText>{t('settings.data.s3.title.help')}</SettingHelpText>
|
||||||
<SettingDivider />
|
<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>
|
<SettingRow>
|
||||||
<SettingRowTitle>{t('settings.data.s3.endpoint.label')}</SettingRowTitle>
|
<SettingRowTitle>{t('settings.data.s3.endpoint.label')}</SettingRowTitle>
|
||||||
<Input
|
<Input
|
||||||
@ -357,6 +331,58 @@ const S3Settings: FC = () => {
|
|||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingHelpText>{t('settings.data.s3.skipBackupFile.help')}</SettingHelpText>
|
<SettingHelpText>{t('settings.data.s3.skipBackupFile.help')}</SettingHelpText>
|
||||||
</SettingRow>
|
</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 && (
|
{syncInterval > 0 && (
|
||||||
<>
|
<>
|
||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import {
|
|||||||
} from '@renderer/store/settings'
|
} from '@renderer/store/settings'
|
||||||
import { Button, Input, Switch, Tooltip } from 'antd'
|
import { Button, Input, Switch, Tooltip } from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { FC, useState } from 'react'
|
import { FC, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||||
@ -63,6 +63,11 @@ const WebDavSettings: FC = () => {
|
|||||||
|
|
||||||
const { webdavSync } = useAppSelector((state) => state.backup)
|
const { webdavSync } = useAppSelector((state) => state.backup)
|
||||||
|
|
||||||
|
// 同步 maxBackups 状态
|
||||||
|
useEffect(() => {
|
||||||
|
setMaxBackups(webDAVMaxBackups)
|
||||||
|
}, [webDAVMaxBackups])
|
||||||
|
|
||||||
// 把之前备份的文件定时上传到 webdav,首先先配置 webdav 的 host, port, user, pass, path
|
// 把之前备份的文件定时上传到 webdav,首先先配置 webdav 的 host, port, user, pass, path
|
||||||
|
|
||||||
const onSyncIntervalChange = (value: number) => {
|
const onSyncIntervalChange = (value: number) => {
|
||||||
@ -193,57 +198,6 @@ const WebDavSettings: FC = () => {
|
|||||||
<SettingGroup theme={theme}>
|
<SettingGroup theme={theme}>
|
||||||
<SettingTitle>{t('settings.data.webdav.title')}</SettingTitle>
|
<SettingTitle>{t('settings.data.webdav.title')}</SettingTitle>
|
||||||
<SettingDivider />
|
<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>
|
<SettingRow>
|
||||||
<SettingRowTitle>{t('settings.data.webdav.host.label')}</SettingRowTitle>
|
<SettingRowTitle>{t('settings.data.webdav.host.label')}</SettingRowTitle>
|
||||||
<Input
|
<Input
|
||||||
@ -357,6 +311,58 @@ const WebDavSettings: FC = () => {
|
|||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingHelpText>{t('settings.data.webdav.disableStream.help')}</SettingHelpText>
|
<SettingHelpText>{t('settings.data.webdav.disableStream.help')}</SettingHelpText>
|
||||||
</SettingRow>
|
</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 && (
|
{webdavSync && syncInterval > 0 && (
|
||||||
<>
|
<>
|
||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { NUTSTORE_HOST } from '@shared/config/nutstore'
|
|||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { type CreateDirectoryOptions } from 'webdav'
|
import { type CreateDirectoryOptions } from 'webdav'
|
||||||
|
|
||||||
|
import { shouldSkipCleanup, validateAndSanitizeFilename } from '../utils/backupUtils'
|
||||||
import { getBackupData, handleData } from './BackupService'
|
import { getBackupData, handleData } from './BackupService'
|
||||||
|
|
||||||
const logger = loggerService.withContext('NutstoreService')
|
const logger = loggerService.withContext('NutstoreService')
|
||||||
@ -109,10 +110,12 @@ async function cleanupOldBackups(webdavConfig: WebDavConfig, maxBackups: number)
|
|||||||
|
|
||||||
export async function backupToNutstore({
|
export async function backupToNutstore({
|
||||||
showMessage = false,
|
showMessage = false,
|
||||||
customFileName = ''
|
customFileName = '',
|
||||||
|
isAutoBackup = false
|
||||||
}: {
|
}: {
|
||||||
showMessage?: boolean
|
showMessage?: boolean
|
||||||
customFileName?: string
|
customFileName?: string
|
||||||
|
isAutoBackup?: boolean
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const nutstoreToken = getNutstoreToken()
|
const nutstoreToken = getNutstoreToken()
|
||||||
if (!nutstoreToken) {
|
if (!nutstoreToken) {
|
||||||
@ -135,21 +138,37 @@ export async function backupToNutstore({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[backupToNutstore] Failed to get device type:', error as 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 backupData = await getBackupData()
|
||||||
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
|
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
|
isManualBackupRunning = true
|
||||||
|
|
||||||
store.dispatch(setNutstoreSyncState({ syncing: true, lastSyncError: null }))
|
store.dispatch(setNutstoreSyncState({ syncing: true, lastSyncError: null }))
|
||||||
|
|
||||||
const backupData = await getBackupData()
|
|
||||||
const skipBackupFile = store.getState().nutstore.nutstoreSkipBackupFile
|
|
||||||
const maxBackups = store.getState().nutstore.nutstoreMaxBackups
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 先清理旧备份
|
// Skip cleanup for single file overwrite mode when maxBackups is 1
|
||||||
await cleanupOldBackups(config, maxBackups)
|
if (!shouldSkipCleanup(maxBackups, singleFileOverwrite)) {
|
||||||
|
// 先清理旧备份
|
||||||
|
await cleanupOldBackups(config, maxBackups)
|
||||||
|
}
|
||||||
|
|
||||||
const isSuccess = await window.api.backup.backupToWebdav(backupData, {
|
const isSuccess = await window.api.backup.backupToWebdav(backupData, {
|
||||||
...config,
|
...config,
|
||||||
@ -264,7 +283,7 @@ export async function startNutstoreAutoSync() {
|
|||||||
isAutoBackupRunning = true
|
isAutoBackupRunning = true
|
||||||
try {
|
try {
|
||||||
logger.verbose('[Nutstore AutoSync] Starting auto backup...')
|
logger.verbose('[Nutstore AutoSync] Starting auto backup...')
|
||||||
await backupToNutstore({ showMessage: false })
|
await backupToNutstore({ showMessage: false, isAutoBackup: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[Nutstore AutoSync] Auto backup failed:', error as Error)
|
logger.error('[Nutstore AutoSync] Auto backup failed:', error as Error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -12,6 +12,8 @@ export interface NutstoreState {
|
|||||||
nutstoreSyncState: NutstoreSyncState
|
nutstoreSyncState: NutstoreSyncState
|
||||||
nutstoreSkipBackupFile: boolean
|
nutstoreSkipBackupFile: boolean
|
||||||
nutstoreMaxBackups: number
|
nutstoreMaxBackups: number
|
||||||
|
nutstoreSingleFileOverwrite: boolean
|
||||||
|
nutstoreSingleFileName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: NutstoreState = {
|
const initialState: NutstoreState = {
|
||||||
@ -25,7 +27,9 @@ const initialState: NutstoreState = {
|
|||||||
lastSyncError: null
|
lastSyncError: null
|
||||||
},
|
},
|
||||||
nutstoreSkipBackupFile: false,
|
nutstoreSkipBackupFile: false,
|
||||||
nutstoreMaxBackups: 0
|
nutstoreMaxBackups: 0,
|
||||||
|
nutstoreSingleFileOverwrite: false,
|
||||||
|
nutstoreSingleFileName: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const nutstoreSlice = createSlice({
|
const nutstoreSlice = createSlice({
|
||||||
@ -52,6 +56,12 @@ const nutstoreSlice = createSlice({
|
|||||||
},
|
},
|
||||||
setNutstoreMaxBackups: (state, action: PayloadAction<number>) => {
|
setNutstoreMaxBackups: (state, action: PayloadAction<number>) => {
|
||||||
state.nutstoreMaxBackups = action.payload
|
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,
|
setNutstoreSyncInterval,
|
||||||
setNutstoreSyncState,
|
setNutstoreSyncState,
|
||||||
setNutstoreSkipBackupFile,
|
setNutstoreSkipBackupFile,
|
||||||
setNutstoreMaxBackups
|
setNutstoreMaxBackups,
|
||||||
|
setNutstoreSingleFileOverwrite,
|
||||||
|
setNutstoreSingleFileName
|
||||||
} = nutstoreSlice.actions
|
} = nutstoreSlice.actions
|
||||||
|
|
||||||
export default nutstoreSlice.reducer
|
export default nutstoreSlice.reducer
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user