From fcc6f68519000e5efa6c268a4197661897e107e4 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:32:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E5=A4=87=E4=BB=BD=E7=AD=96=E7=95=A5=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=20(#686)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/en-US.ts | 15 ++ src/locales/zh-CN.ts | 14 ++ src/locales/zh-TW.ts | 14 ++ src/views/setting/AccountSettingSystem.vue | 125 +++++++++++++ .../__tests__/AccountSettingSystem.spec.ts | 173 +++++++++++++++++- 5 files changed, 339 insertions(+), 2 deletions(-) diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index b32c96fa..f1265c93 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -2219,6 +2219,21 @@ export default { logBackupCountMin: 'Maximum number of log file backups must be greater than or equal to 1', logFileFormat: 'Log File Format', logFileFormatHint: 'Set the output format of log files to customize the displayed content of logs', + dbBackupEnable: 'Enable Data Backup', + dbBackupEnableHint: 'Master switch for automatic database backups; scheduled backups require a backup schedule', + dbBackupCron: 'Backup Schedule', + dbBackupCronHint: 'Cron expression; leave empty to disable scheduled backups', + dbBackupCronInvalid: 'Backup schedule must be empty or a valid Cron expression', + dbBackupPath: 'Backup Directory', + dbBackupPathPlaceholder: '/config/database_backup', + dbBackupPathHint: + 'Leave empty to use database_backup under the configuration directory; local paths are supported', + dbBackupRetentionDays: 'Backup Retention Days', + dbBackupRetentionDaysHint: 'Unit: days. Set to 0 for no limit', + dbBackupRetentionDaysInvalid: 'Backup retention days must be an integer greater than or equal to 0', + dbBackupMaxCount: 'Maximum Backup Count', + dbBackupMaxCountHint: 'Set to 0 for no limit', + dbBackupMaxCountInvalid: 'Maximum backup count must be an integer greater than or equal to 0', dataCleanupEnable: 'Enable Data Cleanup', dataCleanupEnableHint: 'When disabled, scheduled data cleanup tasks will be skipped', dataCleanupDaysRequired: 'Please enter a cleanup retention period', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 11042f46..2b006960 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -2189,6 +2189,20 @@ export default { logBackupCountMin: '日志文件最大备份数量必须大于等于1', logFileFormat: '日志文件格式', logFileFormatHint: '设置日志文件的输出格式,用于自定义日志的显示内容', + dbBackupEnable: '启用数据备份', + dbBackupEnableHint: '数据库自动备份总开关,定时备份仅在配置备份周期后启用', + dbBackupCron: '备份周期', + dbBackupCronHint: 'Cron 表达式,留空不启用定时备份', + dbBackupCronInvalid: '备份周期必须留空或使用有效的 Cron 表达式', + dbBackupPath: '备份目录', + dbBackupPathPlaceholder: '/config/database_backup', + dbBackupPathHint: '留空使用配置目录下的 database_backup,支持本地存储路径', + dbBackupRetentionDays: '备份过期天数', + dbBackupRetentionDaysHint: '单位:天,0 表示不限制', + dbBackupRetentionDaysInvalid: '备份过期天数必须是大于等于 0 的整数', + dbBackupMaxCount: '最大保留份数', + dbBackupMaxCountHint: '0 表示不限制', + dbBackupMaxCountInvalid: '最大保留份数必须是大于等于 0 的整数', dataCleanupEnable: '启用数据清理', dataCleanupEnableHint: '总开关关闭时将跳过定时数据清理任务', dataCleanupDaysRequired: '请输入清理周期', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 5b834692..4bbbdc4f 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -2188,6 +2188,20 @@ export default { logBackupCountMin: '日誌文件最大備份數量必須大於等於1', logFileFormat: '日誌文件格式', logFileFormatHint: '設置日誌文件的輸出格式,用於自定義日誌的顯示內容', + dbBackupEnable: '啟用數據備份', + dbBackupEnableHint: '數據庫自動備份總開關,定時備份僅在配置備份週期後啟用', + dbBackupCron: '備份週期', + dbBackupCronHint: 'Cron 表達式,留空不啟用定時備份', + dbBackupCronInvalid: '備份週期必須留空或使用有效的 Cron 表達式', + dbBackupPath: '備份目錄', + dbBackupPathPlaceholder: '/config/database_backup', + dbBackupPathHint: '留空使用配置目錄下的 database_backup,支持本地存儲路徑', + dbBackupRetentionDays: '備份過期天數', + dbBackupRetentionDaysHint: '單位:天,0 表示不限制', + dbBackupRetentionDaysInvalid: '備份過期天數必須是大於等於 0 的整數', + dbBackupMaxCount: '最大保留份數', + dbBackupMaxCountHint: '0 表示不限制', + dbBackupMaxCountInvalid: '最大保留份數必須是大於等於 0 的整數', dataCleanupEnable: '啟用數據清理', dataCleanupEnableHint: '總開關關閉時將跳過定時數據清理任務', dataCleanupDaysRequired: '請輸入清理週期', diff --git a/src/views/setting/AccountSettingSystem.vue b/src/views/setting/AccountSettingSystem.vue index bd29e93f..b9e7050f 100644 --- a/src/views/setting/AccountSettingSystem.vue +++ b/src/views/setting/AccountSettingSystem.vue @@ -100,6 +100,12 @@ const SystemSettings = ref({ DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS: 180, DATA_CLEANUP_SITE_USERDATA_DAYS: 180, DATA_CLEANUP_TRANSFER_HISTORY_DAYS: 365 * 3, + // 本地数据库备份策略同时适用于 SQLite 与 PostgreSQL。 + DB_BACKUP_ENABLE: false, + DB_BACKUP_CRON: '0 3 * * *', + DB_BACKUP_PATH: null, + DB_BACKUP_RETENTION_DAYS: 30, + DB_BACKUP_MAX_COUNT: 30, // 媒体 RECOGNIZE_PLUGIN_FIRST: false, MEDIA_RECOGNIZE_SHARE: true, @@ -610,6 +616,36 @@ const dataCleanupFieldRules = [ (value: unknown) => Number(value) >= 0 || t('setting.system.dataCleanupDaysMin'), ] +const dbBackupPath = computed({ + get: () => String(SystemSettings.value.Advanced.DB_BACKUP_PATH ?? ''), + set: (value: string) => { + SystemSettings.value.Advanced.DB_BACKUP_PATH = value + }, +}) + +function hasValidCronFieldCount(value: string) { + return value.trim().split(/\s+/).length === 5 +} + +function isNonNegativeInteger(value: unknown) { + if (value === '' || value === null || value === undefined) return false + const numberValue = Number(value) + return Number.isInteger(numberValue) && numberValue >= 0 +} + +const dbBackupCronRules = [ + (value: unknown) => { + const cron = String(value ?? '').trim() + return !cron || hasValidCronFieldCount(cron) || t('setting.system.dbBackupCronInvalid') + }, +] +const dbBackupRetentionRules = [ + (value: unknown) => isNonNegativeInteger(value) || t('setting.system.dbBackupRetentionDaysInvalid'), +] +const dbBackupMaxCountRules = [ + (value: unknown) => isNonNegativeInteger(value) || t('setting.system.dbBackupMaxCountInvalid'), +] + // 安全域名添加变量 const newSecurityDomain = ref('') // 图片代理允许非公网网段添加变量 @@ -859,6 +895,7 @@ async function testLlmConnection() { // 保存高级设置 async function saveAdvancedSettings() { + if (!normalizeDbBackupSettings()) return if (!rustAccelAvailable.value) SystemSettings.value.Advanced.RUST_ACCEL = false cleanEmptyFields(SystemSettings.value.Advanced, ['LOG_FILE_FORMAT']) @@ -876,6 +913,35 @@ async function saveAdvancedSettings() { } } +/** 规范化备份策略,并在请求前阻止后端无法执行的配置。 */ +function normalizeDbBackupSettings() { + const settings = SystemSettings.value.Advanced + const path = String(settings.DB_BACKUP_PATH ?? '').trim() + settings.DB_BACKUP_PATH = path || null + + const cron = String(settings.DB_BACKUP_CRON ?? '').trim() + const retentionDays = Number(settings.DB_BACKUP_RETENTION_DAYS) + const maxCount = Number(settings.DB_BACKUP_MAX_COUNT) + + if (cron && !hasValidCronFieldCount(cron)) { + $toast.error(t('setting.system.dbBackupCronInvalid')) + return false + } + if (!isNonNegativeInteger(settings.DB_BACKUP_RETENTION_DAYS)) { + $toast.error(t('setting.system.dbBackupRetentionDaysInvalid')) + return false + } + if (!isNonNegativeInteger(settings.DB_BACKUP_MAX_COUNT)) { + $toast.error(t('setting.system.dbBackupMaxCountInvalid')) + return false + } + + settings.DB_BACKUP_CRON = cron + settings.DB_BACKUP_RETENTION_DAYS = retentionDays + settings.DB_BACKUP_MAX_COUNT = maxCount + return true +} + // 当字段为空时,将其设置为 null 提交,以便后端恢复为默认值 function cleanEmptyFields(settings: Record, fields: string[]) { fields.forEach(field => { @@ -2413,6 +2479,65 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
+ + + + ({ @@ -278,10 +278,44 @@ function enableLlmSettings() { ) } +/** 为异步表单字段提供可真实回写 v-model 的测试边界。 */ +function createModelFieldStub(name: string) { + return defineComponent({ + name, + inheritAttrs: false, + props: { + label: { type: String, default: '' }, + modelValue: { default: '' }, + placeholder: { type: String, default: '' }, + storage: { type: String, default: undefined }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => + h('label', [ + h('span', props.label), + h('input', { + 'aria-label': props.label, + 'data-storage': props.storage, + placeholder: props.placeholder, + value: props.modelValue ?? '', + onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value), + }), + ]) + }, + }) +} + +const CronFieldStub = createModelFieldStub('VCronFieldStub') +const PathFieldStub = createModelFieldStub('VPathFieldStub') + async function renderSettings(props: { active?: boolean } = {}) { return renderWithProviders(AccountSettingSystem, { props, - global: { stubs: { VDialogCloseBtn: true } }, + global: { + components: { VCronField: CronFieldStub, VPathField: PathFieldStub }, + stubs: { VDialogCloseBtn: true }, + }, stubActions: false, }) } @@ -1015,6 +1049,141 @@ describe('AccountSettingSystem', () => { ) }) + it.each(['sqlite', 'postgresql'])('shows database backup defaults for %s', async databaseType => { + systemEnv.DB_TYPE = databaseType + await renderSettings() + const dialog = await openAdvancedTab('数据') + + expect(dialog.getByLabelText('启用数据备份')).not.toBeChecked() + expect(dialog.queryByLabelText('备份周期')).not.toBeInTheDocument() + + await fireEvent.click(dialog.getByLabelText('启用数据备份')) + + expect(dialog.getByLabelText('备份周期')).toHaveValue('0 3 * * *') + expect(dialog.getByLabelText('备份目录')).toHaveValue('') + expect(dialog.getByLabelText('备份目录')).toHaveAttribute('placeholder', '/config/database_backup') + expect(dialog.getByLabelText('备份目录')).toHaveAttribute('data-storage', 'local') + expect(dialog.getByLabelText('备份过期天数')).toHaveValue(30) + expect(dialog.getByLabelText('最大保留份数')).toHaveValue(30) + }) + + it('loads, edits, and saves the database backup policy', async () => { + systemEnv = { + ...systemEnv, + DB_BACKUP_CRON: '15 2 * * 1', + DB_BACKUP_ENABLE: true, + DB_BACKUP_MAX_COUNT: 12, + DB_BACKUP_PATH: '/data/backup', + DB_BACKUP_RETENTION_DAYS: 45, + } + await renderSettings() + const dialog = await openAdvancedTab('数据') + + expect(dialog.getByLabelText('备份周期')).toHaveValue('15 2 * * 1') + expect(dialog.getByLabelText('备份目录')).toHaveValue('/data/backup') + await fireEvent.update(dialog.getByLabelText('备份周期'), '30 4 * * *') + await fireEvent.update(dialog.getByLabelText('备份目录'), ' relative/backup ') + await fireEvent.update(dialog.getByLabelText('备份过期天数'), '60') + await fireEvent.update(dialog.getByLabelText('最大保留份数'), '20') + await fireEvent.click(dialog.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(findPost('system/env')?.[1]).toEqual( + expect.objectContaining({ + DB_BACKUP_CRON: '30 4 * * *', + DB_BACKUP_ENABLE: true, + DB_BACKUP_MAX_COUNT: 20, + DB_BACKUP_PATH: 'relative/backup', + DB_BACKUP_RETENTION_DAYS: 60, + }), + ) + }) + + it('hides disabled backup fields while preserving their values', async () => { + systemEnv = { + ...systemEnv, + DB_BACKUP_CRON: ' 15 2 * * 1 ', + DB_BACKUP_ENABLE: true, + DB_BACKUP_MAX_COUNT: '12', + DB_BACKUP_PATH: ' /data/backup ', + DB_BACKUP_RETENTION_DAYS: '45', + } + await renderSettings() + const dialog = await openAdvancedTab('数据') + + await fireEvent.click(dialog.getByLabelText('启用数据备份')) + expect(dialog.queryByLabelText('备份周期')).not.toBeInTheDocument() + await fireEvent.click(dialog.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(findPost('system/env')?.[1]).toEqual( + expect.objectContaining({ + DB_BACKUP_CRON: '15 2 * * 1', + DB_BACKUP_ENABLE: false, + DB_BACKUP_MAX_COUNT: 12, + DB_BACKUP_PATH: '/data/backup', + DB_BACKUP_RETENTION_DAYS: 45, + }), + ) + }) + + it('rejects invalid hidden values while backup is disabled', async () => { + systemEnv = { ...systemEnv, DB_BACKUP_ENABLE: true, DB_BACKUP_PATH: '/data/backup' } + await renderSettings() + const dialog = await openAdvancedTab('数据') + + await fireEvent.update(dialog.getByLabelText('备份周期'), '* * * *') + await fireEvent.update(dialog.getByLabelText('备份目录'), ' ') + await fireEvent.update(dialog.getByLabelText('备份过期天数'), '-1') + await fireEvent.update(dialog.getByLabelText('最大保留份数'), '1.5') + await fireEvent.click(dialog.getByLabelText('启用数据备份')) + await fireEvent.click(dialog.getByRole('button', { name: '保存' })) + + expect(mocks.apiPost).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledWith('备份周期必须留空或使用有效的 Cron 表达式') + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('accepts zero backup limits and normalizes a blank path to the backend default', async () => { + systemEnv = { ...systemEnv, DB_BACKUP_ENABLE: true, DB_BACKUP_PATH: null } + await renderSettings() + const dialog = await openAdvancedTab('数据') + + expect(dialog.getByLabelText('备份目录')).toHaveValue('') + await fireEvent.update(dialog.getByLabelText('备份周期'), '') + await fireEvent.update(dialog.getByLabelText('备份目录'), ' ') + await fireEvent.update(dialog.getByLabelText('备份过期天数'), '0') + await fireEvent.update(dialog.getByLabelText('最大保留份数'), '0') + await fireEvent.click(dialog.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(findPost('system/env')?.[1]).toEqual( + expect.objectContaining({ + DB_BACKUP_CRON: '', + DB_BACKUP_MAX_COUNT: 0, + DB_BACKUP_PATH: null, + DB_BACKUP_RETENTION_DAYS: 0, + }), + ) + }) + + it.each([ + ['备份周期', '* * * *', '备份周期必须留空或使用有效的 Cron 表达式'], + ['备份过期天数', '-1', '备份过期天数必须是大于等于 0 的整数'], + ['最大保留份数', '1.5', '最大保留份数必须是大于等于 0 的整数'], + ])('rejects invalid database backup field %s before posting', async (label, value, message) => { + systemEnv.DB_BACKUP_ENABLE = true + await renderSettings() + const dialog = await openAdvancedTab('数据') + + await fireEvent.update(dialog.getByLabelText(label), value) + await fireEvent.click(dialog.getByRole('button', { name: '保存' })) + + expect(mocks.apiPost).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledWith(message) + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + it('round-trips every advanced laboratory control when Rust is available', async () => { systemEnv.RUST_ACCEL_AVAILABLE = true await renderSettings()