feat: 完善数据库备份管理界面 (#717)

* feat(settings): add database backup management

* feat(settings): complete database backup controls
This commit is contained in:
InfinityPacer
2026-08-25 13:00:10 +08:00
committed by GitHub
parent 62b2eae120
commit 94eac98207
8 changed files with 533 additions and 30 deletions
+37
View File
@@ -0,0 +1,37 @@
import api from './index'
/** Web 管理端可见的受管数据库备份摘要。 */
export interface DatabaseBackupArtifact {
name: string
db_type: string
created_at: string
size: number
}
/** 受管数据库备份的脱敏校验结果。 */
export interface DatabaseBackupVerification {
valid: boolean
method: string
}
/** 查询当前备份目录中的正式制品。 */
export function listDatabaseBackups(): Promise<DatabaseBackupArtifact[]> {
return api.get('system/database/backups', { feedback: 'silent' })
}
/** 创建、校验并发布当前活动数据库的一致快照。 */
export function createDatabaseBackup(): Promise<DatabaseBackupArtifact> {
return api.post('system/database/backups', undefined, { feedback: 'silent' })
}
/** 重新校验指定受管备份。 */
export function verifyDatabaseBackup(name: string): Promise<DatabaseBackupVerification> {
return api.post(`system/database/backups/${encodeURIComponent(name)}/verify`, undefined, {
feedback: 'silent',
})
}
/** 删除指定受管备份文件。 */
export function deleteDatabaseBackup(name: string): Promise<void> {
return api.delete(`system/database/backups/${encodeURIComponent(name)}`, { feedback: 'silent' })
}
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { formatBytes } from '@core/utils/formatters'
import {
createDatabaseBackup,
deleteDatabaseBackup,
listDatabaseBackups,
verifyDatabaseBackup,
type DatabaseBackupArtifact,
} from '@/api/databaseBackup'
import { useConfirm } from '@/composables/useConfirm'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ active: boolean }>()
const { t, locale } = useI18n()
const toast = useToast()
const createConfirm = useConfirm()
const databaseBackupGuideUrl =
'https://github.com/jxxghp/MoviePilot/blob/v3/docs/cli.md#%E6%95%B0%E6%8D%AE%E5%BA%93%E5%A4%87%E4%BB%BD%E5%91%BD%E4%BB%A4'
const backups = ref<DatabaseBackupArtifact[]>([])
const loading = ref(false)
const creating = ref(false)
const loaded = ref(false)
const loadFailed = ref(false)
const verifyingNames = ref(new Set<string>())
const deletingNames = ref(new Set<string>())
function formatCreatedAt(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium',
timeStyle: 'medium',
}).format(date)
}
function databaseTypeLabel(value: string): string {
return value === 'postgresql' ? 'PostgreSQL' : 'SQLite'
}
async function loadBackups() {
if (loading.value) return
loading.value = true
loadFailed.value = false
try {
backups.value = await listDatabaseBackups()
loaded.value = true
} catch {
loadFailed.value = true
} finally {
loading.value = false
}
}
async function createBackup() {
if (creating.value) return
creating.value = true
try {
await createDatabaseBackup()
toast.success(t('setting.system.dbBackupCreateSuccess'))
await loadBackups()
} catch {
toast.error(t('setting.system.dbBackupCreateFailed'))
} finally {
creating.value = false
}
}
async function verifyBackup(name: string) {
if (verifyingNames.value.has(name)) return
verifyingNames.value = new Set(verifyingNames.value).add(name)
try {
const result = await verifyDatabaseBackup(name)
if (result.valid) toast.success(t('setting.system.dbBackupVerifySuccess'))
else toast.error(t('setting.system.dbBackupVerifyInvalid'))
} catch {
toast.error(t('setting.system.dbBackupVerifyFailed'))
} finally {
const next = new Set(verifyingNames.value)
next.delete(name)
verifyingNames.value = next
}
}
async function deleteBackup(name: string) {
if (deletingNames.value.has(name)) return
const confirmed = await createConfirm({
type: 'warn',
title: t('setting.system.dbBackupDeleteTitle'),
content: t('setting.system.dbBackupDeleteConfirm', { name }),
confirmText: t('setting.system.dbBackupDelete'),
})
if (!confirmed) return
deletingNames.value = new Set(deletingNames.value).add(name)
try {
await deleteDatabaseBackup(name)
backups.value = backups.value.filter(backup => backup.name !== name)
toast.success(t('setting.system.dbBackupDeleteSuccess'))
} catch {
toast.error(t('setting.system.dbBackupDeleteFailed'))
} finally {
const next = new Set(deletingNames.value)
next.delete(name)
deletingNames.value = next
}
}
watch(
() => props.active,
active => {
if (active && !loaded.value) void loadBackups()
},
{ immediate: true },
)
</script>
<template>
<section class="database-backup-panel" :aria-label="t('setting.system.dbBackupManagement')">
<VDivider class="mb-5" />
<div class="d-flex flex-wrap align-center justify-space-between gap-3 mb-3">
<div>
<div class="d-flex align-center text-subtitle-1 font-weight-medium">
<VIcon icon="mdi-database-clock-outline" class="me-2" />
{{ t('setting.system.dbBackupManagement') }}
</div>
<div class="text-body-2 text-medium-emphasis mt-1 d-flex flex-wrap align-center gap-1">
<span>{{ t('setting.system.dbBackupManagementHint') }}</span>
<a
:href="databaseBackupGuideUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary"
>
{{ t('setting.system.dbBackupRestoreGuide') }}
</a>
</div>
</div>
<div class="d-flex align-center gap-2">
<VTooltip :text="t('setting.system.dbBackupRefresh')">
<template #activator="{ props: tooltipProps }">
<VBtn
v-bind="tooltipProps"
icon="mdi-refresh"
size="small"
variant="text"
:aria-label="t('setting.system.dbBackupRefresh')"
:loading="loading"
@click="loadBackups"
/>
</template>
</VTooltip>
<VBtn
color="primary"
variant="tonal"
prepend-icon="mdi-database-plus-outline"
:loading="creating"
:disabled="loading"
@click="createBackup"
>
{{ t('setting.system.dbBackupCreate') }}
</VBtn>
</div>
</div>
<VAlert v-if="loadFailed" type="error" variant="tonal" density="compact" class="mb-3">
<div class="d-flex flex-wrap align-center justify-space-between gap-2">
<span>{{ t('setting.system.dbBackupLoadFailed') }}</span>
<VBtn size="small" variant="text" prepend-icon="mdi-refresh" @click="loadBackups">
{{ t('common.retry') }}
</VBtn>
</div>
</VAlert>
<VProgressLinear v-if="loading && !loaded" indeterminate color="primary" class="mb-2" />
<div class="database-backup-table">
<VTable density="compact" hover>
<thead>
<tr>
<th>{{ t('setting.system.dbBackupName') }}</th>
<th>{{ t('setting.system.dbBackupType') }}</th>
<th>{{ t('setting.system.dbBackupCreatedAt') }}</th>
<th>{{ t('setting.system.dbBackupSize') }}</th>
<th class="text-end">{{ t('setting.system.dbBackupActions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="backup in backups" :key="backup.name">
<td class="font-weight-medium text-no-wrap">{{ backup.name }}</td>
<td>{{ databaseTypeLabel(backup.db_type) }}</td>
<td class="text-no-wrap">{{ formatCreatedAt(backup.created_at) }}</td>
<td class="text-no-wrap">{{ formatBytes(backup.size) }}</td>
<td class="text-end">
<VMenu location="bottom end">
<template #activator="{ props: menuProps }">
<VBtn
v-bind="menuProps"
icon="mdi-dots-vertical"
size="small"
variant="text"
:aria-label="t('setting.system.dbBackupActionsName', { name: backup.name })"
:loading="verifyingNames.has(backup.name) || deletingNames.has(backup.name)"
/>
</template>
<VList density="compact">
<VListItem
prepend-icon="mdi-shield-check-outline"
:title="t('setting.system.dbBackupVerify')"
@click="verifyBackup(backup.name)"
/>
<VListItem
prepend-icon="mdi-delete-outline"
:title="t('setting.system.dbBackupDelete')"
class="text-error"
@click="deleteBackup(backup.name)"
/>
</VList>
</VMenu>
</td>
</tr>
<tr v-if="loaded && backups.length === 0">
<td colspan="5" class="text-center text-medium-emphasis py-8">
<VIcon icon="mdi-database-off-outline" size="28" class="mb-2" />
<div>{{ t('setting.system.dbBackupEmpty') }}</div>
</td>
</tr>
</tbody>
</VTable>
</div>
</section>
</template>
<style scoped>
.database-backup-table {
max-block-size: clamp(220px, 34vh, 360px);
overflow: auto;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 6px;
}
.database-backup-table :deep(th) {
position: sticky;
z-index: 1;
inset-block-start: 0;
background: rgb(var(--v-theme-surface));
white-space: nowrap;
}
</style>
@@ -0,0 +1,139 @@
import DatabaseBackupPanel from '@/components/system/DatabaseBackupPanel.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
createBackup: vi.fn(),
deleteBackup: vi.fn(),
listBackups: vi.fn(),
confirm: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
verifyBackup: vi.fn(),
}))
vi.mock('@/api/databaseBackup', () => ({
createDatabaseBackup: mocks.createBackup,
deleteDatabaseBackup: mocks.deleteBackup,
listDatabaseBackups: mocks.listBackups,
verifyDatabaseBackup: mocks.verifyBackup,
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => mocks.confirm,
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({
error: mocks.toastError,
success: mocks.toastSuccess,
}),
}))
const existingBackup = {
name: 'moviepilot_v3.0.0_sqlite_20260825_120000.db',
db_type: 'sqlite',
created_at: '2026-08-25T12:00:00',
size: 4096,
}
describe('DatabaseBackupPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.listBackups.mockResolvedValue([existingBackup])
mocks.createBackup.mockResolvedValue(existingBackup)
mocks.deleteBackup.mockResolvedValue(undefined)
mocks.confirm.mockResolvedValue(true)
mocks.verifyBackup.mockResolvedValue({ valid: true, method: 'PRAGMA integrity_check' })
})
it('loads only after activation and exposes managed backup fields', async () => {
const view = await renderWithProviders(DatabaseBackupPanel, { props: { active: false } })
expect(mocks.listBackups).not.toHaveBeenCalled()
await view.rerender({ active: true })
expect(await screen.findByText(existingBackup.name)).toBeInTheDocument()
expect(screen.getByText('SQLite')).toBeInTheDocument()
expect(screen.getByText('4 KB')).toBeInTheDocument()
expect(screen.queryByText('未校验')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: '查看还原命令' })).toHaveAttribute(
'href',
expect.stringContaining('docs/cli.md'),
)
})
it('refreshes the list after creating a backup', async () => {
const newBackup = {
...existingBackup,
name: 'moviepilot_v3.0.0_sqlite_20260825_120001.db',
created_at: '2026-08-25T12:00:01',
}
mocks.listBackups.mockResolvedValueOnce([existingBackup]).mockResolvedValueOnce([newBackup, existingBackup])
mocks.createBackup.mockResolvedValue(newBackup)
await renderWithProviders(DatabaseBackupPanel, { props: { active: true } })
await screen.findByText(existingBackup.name)
await fireEvent.click(screen.getByRole('button', { name: '立即备份' }))
expect(await screen.findByText(newBackup.name)).toBeInTheDocument()
expect(mocks.createBackup).toHaveBeenCalledTimes(1)
expect(mocks.listBackups).toHaveBeenCalledTimes(2)
expect(mocks.toastSuccess).toHaveBeenCalledWith('数据库备份已创建')
})
it('reports the result when verifying a backup', async () => {
await renderWithProviders(DatabaseBackupPanel, { props: { active: true } })
await screen.findByText(existingBackup.name)
await fireEvent.click(screen.getByRole('button', { name: `管理备份 ${existingBackup.name}` }))
await fireEvent.click(await screen.findByText('校验'))
await waitFor(() => expect(mocks.verifyBackup).toHaveBeenCalledWith(existingBackup.name))
expect(mocks.verifyBackup).toHaveBeenCalledWith(existingBackup.name)
expect(mocks.toastSuccess).toHaveBeenCalledWith('数据库备份校验通过')
})
it('deletes a backup after confirmation and removes its row', async () => {
await renderWithProviders(DatabaseBackupPanel, { props: { active: true } })
await screen.findByText(existingBackup.name)
await fireEvent.click(screen.getByRole('button', { name: `管理备份 ${existingBackup.name}` }))
await fireEvent.click(await screen.findByText('删除'))
await waitFor(() => expect(screen.queryByText(existingBackup.name)).not.toBeInTheDocument())
expect(mocks.confirm).toHaveBeenCalledWith(
expect.objectContaining({
title: '删除数据库备份',
content: `确定删除 ${existingBackup.name} 吗?删除后无法恢复。`,
}),
)
expect(mocks.deleteBackup).toHaveBeenCalledWith(existingBackup.name)
expect(mocks.toastSuccess).toHaveBeenCalledWith('数据库备份已删除')
})
it('keeps the backup when deletion is cancelled', async () => {
mocks.confirm.mockResolvedValue(false)
await renderWithProviders(DatabaseBackupPanel, { props: { active: true } })
await screen.findByText(existingBackup.name)
await fireEvent.click(screen.getByRole('button', { name: `管理备份 ${existingBackup.name}` }))
await fireEvent.click(await screen.findByText('删除'))
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1))
expect(mocks.deleteBackup).not.toHaveBeenCalled()
expect(screen.getByText(existingBackup.name)).toBeInTheDocument()
})
it('keeps the panel recoverable after a list failure', async () => {
mocks.listBackups.mockRejectedValueOnce(new Error('unavailable')).mockResolvedValueOnce([])
await renderWithProviders(DatabaseBackupPanel, { props: { active: true } })
expect(await screen.findByText('备份列表加载失败')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByText('暂无数据库备份')).toBeInTheDocument()
expect(mocks.listBackups).toHaveBeenCalledTimes(2)
})
})
+29 -7
View File
@@ -2250,7 +2250,7 @@ export default {
logFileFormat: 'Log File Format',
logFileFormatHint: 'Set the output format of log files to customize the displayed content of logs',
dbBackupEnable: 'Enable Data Backup',
dbBackupEnableHint: 'Enable scheduled backups and backups before database migrations',
dbBackupEnableHint: 'Create database backups on a schedule',
dbBackupCron: 'Backup Schedule',
dbBackupCronHint: 'Cron expression; leave empty to disable scheduled backups',
dbBackupCronInvalid: 'Backup schedule must be empty or a valid Cron expression',
@@ -2264,18 +2264,40 @@ export default {
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',
dbBackupOnUpgrade: 'Back Up Before Database Migration',
dbBackupOnUpgradeHint:
'Automatically create a backup when pending migrations are detected for an existing database',
dbBackupOnUpgrade: 'Back Up Before Upgrading',
dbBackupOnUpgradeHint: 'Create a backup when a database schema upgrade is detected',
dbBackupManagement: 'Database Backups',
dbBackupManagementHint: 'Stop MoviePilot before restoring a backup.',
dbBackupRestoreGuide: 'View restore commands',
dbBackupRefresh: 'Refresh backup list',
dbBackupCreate: 'Back Up Now',
dbBackupCreateSuccess: 'Database backup created',
dbBackupCreateFailed: 'Database backup failed. Check the logs for details',
dbBackupLoadFailed: 'Failed to load database backups',
dbBackupName: 'Backup Name',
dbBackupType: 'Type',
dbBackupCreatedAt: 'Created',
dbBackupSize: 'Size',
dbBackupActions: 'Actions',
dbBackupActionsName: 'Manage backup {name}',
dbBackupVerify: 'Verify',
dbBackupVerifySuccess: 'Database backup verified',
dbBackupVerifyInvalid: 'Database backup verification failed',
dbBackupVerifyFailed: 'Could not verify the database backup. Check the logs for details',
dbBackupDelete: 'Delete',
dbBackupDeleteTitle: 'Delete Database Backup',
dbBackupDeleteConfirm: 'Delete {name}? This cannot be undone.',
dbBackupDeleteSuccess: 'Database backup deleted',
dbBackupDeleteFailed: 'Could not delete the database backup. Check the logs for details',
dbBackupEmpty: 'No database backups',
dataCleanupEnable: 'Enable Data Cleanup',
dataCleanupEnableHint: 'Automatically remove expired data using the configured retention periods',
dataCleanupEnableHint: 'Automatically remove data older than its retention period',
dataCleanupDaysRequired: 'Please enter a cleanup retention period',
dataCleanupDaysMin: 'Cleanup retention period must be greater than or equal to 0',
dataCleanupMessageDays: 'Message Retention Days',
dataCleanupMessageDaysHint: 'Unit: days. Set to 0 to skip cleanup for the message table',
dataCleanupDownloadHistoryDays: 'Download History Retention Days',
dataCleanupDownloadHistoryDaysHint:
'Unit: days. Set to 0 to skip cleanup for download history and its related orphaned download file records',
dataCleanupDownloadHistoryDaysHint: 'Set to 0 to keep all download history',
dataCleanupSiteUserDataDays: 'Site User Data Retention Days',
dataCleanupSiteUserDataDaysHint: 'Unit: days. Set to 0 to skip cleanup for the site user data table',
dataCleanupTransferHistoryDays: 'Transfer History Retention Days',
+29 -5
View File
@@ -2219,7 +2219,7 @@ export default {
logFileFormat: '日志文件格式',
logFileFormatHint: '设置日志文件的输出格式,用于自定义日志的显示内容',
dbBackupEnable: '启用数据备份',
dbBackupEnableHint: '启用后可进行定时备份和数据库迁移前备份',
dbBackupEnableHint: '按计划自动创建数据库备份',
dbBackupCron: '备份周期',
dbBackupCronHint: 'Cron 表达式,留空不启用定时备份',
dbBackupCronInvalid: '备份周期必须留空或使用有效的 Cron 表达式',
@@ -2232,16 +2232,40 @@ export default {
dbBackupMaxCount: '最大保留份数',
dbBackupMaxCountHint: '0 表示不限制',
dbBackupMaxCountInvalid: '最大保留份数必须是大于等于 0 的整数',
dbBackupOnUpgrade: '数据库迁移前备份',
dbBackupOnUpgradeHint: '检测到现有数据库存在待执行迁移时自动创建备份',
dbBackupOnUpgrade: '升级前自动备份',
dbBackupOnUpgradeHint: '检测到数据库结构需要升级时,自动创建备份',
dbBackupManagement: '数据库备份',
dbBackupManagementHint: '还原备份前必须停止 MoviePilot。',
dbBackupRestoreGuide: '查看还原命令',
dbBackupRefresh: '刷新备份列表',
dbBackupCreate: '立即备份',
dbBackupCreateSuccess: '数据库备份已创建',
dbBackupCreateFailed: '数据库备份创建失败,请查看日志',
dbBackupLoadFailed: '备份列表加载失败',
dbBackupName: '备份名称',
dbBackupType: '类型',
dbBackupCreatedAt: '创建时间',
dbBackupSize: '大小',
dbBackupActions: '操作',
dbBackupActionsName: '管理备份 {name}',
dbBackupVerify: '校验',
dbBackupVerifySuccess: '数据库备份校验通过',
dbBackupVerifyInvalid: '数据库备份校验未通过',
dbBackupVerifyFailed: '数据库备份校验失败,请查看日志',
dbBackupDelete: '删除',
dbBackupDeleteTitle: '删除数据库备份',
dbBackupDeleteConfirm: '确定删除 {name} 吗?删除后无法恢复。',
dbBackupDeleteSuccess: '数据库备份已删除',
dbBackupDeleteFailed: '数据库备份删除失败,请查看日志',
dbBackupEmpty: '暂无数据库备份',
dataCleanupEnable: '启用数据清理',
dataCleanupEnableHint: '启用后按设置的保留天数自动清理过期数据',
dataCleanupEnableHint: '自动清理超过保留期限的数据',
dataCleanupDaysRequired: '请输入清理周期',
dataCleanupDaysMin: '清理周期必须大于等于0',
dataCleanupMessageDays: '消息表保留天数',
dataCleanupMessageDaysHint: '单位:天,0 表示不清理消息表数据',
dataCleanupDownloadHistoryDays: '下载历史表保留天数',
dataCleanupDownloadHistoryDaysHint: '单位:天,0 表示不清理下载历史及其关联的下载文件孤儿记录',
dataCleanupDownloadHistoryDaysHint: '0 表示保留全部下载历史',
dataCleanupSiteUserDataDays: '站点数据表保留天数',
dataCleanupSiteUserDataDaysHint: '单位:天,0 表示不清理站点用户数据表',
dataCleanupTransferHistoryDays: '整理历史表保留天数',
+29 -5
View File
@@ -2218,7 +2218,7 @@ export default {
logFileFormat: '日誌文件格式',
logFileFormatHint: '設置日誌文件的輸出格式,用於自定義日誌的顯示內容',
dbBackupEnable: '啟用數據備份',
dbBackupEnableHint: '啟用後可進行定時備份和資料庫遷移前備份',
dbBackupEnableHint: '按計劃自動建立資料庫備份',
dbBackupCron: '備份週期',
dbBackupCronHint: 'Cron 表達式,留空不啟用定時備份',
dbBackupCronInvalid: '備份週期必須留空或使用有效的 Cron 表達式',
@@ -2231,16 +2231,40 @@ export default {
dbBackupMaxCount: '最大保留份數',
dbBackupMaxCountHint: '0 表示不限制',
dbBackupMaxCountInvalid: '最大保留份數必須是大於等於 0 的整數',
dbBackupOnUpgrade: '資料庫遷移前備份',
dbBackupOnUpgradeHint: '測到現有資料庫存在待執行遷移時自動建立備份',
dbBackupOnUpgrade: '升級前自動備份',
dbBackupOnUpgradeHint: '測到資料庫結構需要升級時,自動建立備份',
dbBackupManagement: '資料庫備份',
dbBackupManagementHint: '還原備份前必須停止 MoviePilot。',
dbBackupRestoreGuide: '查看還原命令',
dbBackupRefresh: '重新整理備份清單',
dbBackupCreate: '立即備份',
dbBackupCreateSuccess: '資料庫備份已建立',
dbBackupCreateFailed: '資料庫備份建立失敗,請查看日誌',
dbBackupLoadFailed: '備份清單載入失敗',
dbBackupName: '備份名稱',
dbBackupType: '類型',
dbBackupCreatedAt: '建立時間',
dbBackupSize: '大小',
dbBackupActions: '操作',
dbBackupActionsName: '管理備份 {name}',
dbBackupVerify: '校驗',
dbBackupVerifySuccess: '資料庫備份檢查通過',
dbBackupVerifyInvalid: '資料庫備份檢查未通過',
dbBackupVerifyFailed: '資料庫備份檢查失敗,請查看日誌',
dbBackupDelete: '刪除',
dbBackupDeleteTitle: '刪除資料庫備份',
dbBackupDeleteConfirm: '確定刪除 {name} 嗎?刪除後無法復原。',
dbBackupDeleteSuccess: '資料庫備份已刪除',
dbBackupDeleteFailed: '資料庫備份刪除失敗,請查看日誌',
dbBackupEmpty: '暫無資料庫備份',
dataCleanupEnable: '啟用數據清理',
dataCleanupEnableHint: '啟用後按設定的保留天數自動清理過期資料',
dataCleanupEnableHint: '自動清理超過保留期限的資料',
dataCleanupDaysRequired: '請輸入清理週期',
dataCleanupDaysMin: '清理週期必須大於等於0',
dataCleanupMessageDays: '消息表保留天數',
dataCleanupMessageDaysHint: '單位:天,0 表示不清理消息表數據',
dataCleanupDownloadHistoryDays: '下載歷史表保留天數',
dataCleanupDownloadHistoryDaysHint: '單位:天,0 表示不清理下載歷史及其關聯的下載文件孤兒記錄',
dataCleanupDownloadHistoryDaysHint: '0 表示保留全部下載歷史',
dataCleanupSiteUserDataDays: '站點數據表保留天數',
dataCleanupSiteUserDataDaysHint: '單位:天,0 表示不清理站點用戶數據表',
dataCleanupTransferHistoryDays: '整理歷史表保留天數',
+13 -9
View File
@@ -7,6 +7,7 @@ import { useGlobalSettingsStore } from '@/stores'
import { DownloaderConf, MediaServerConf } from '@/api/types'
import DownloaderCard from '@/components/cards/DownloaderCard.vue'
import MediaServerCard from '@/components/cards/MediaServerCard.vue'
import DatabaseBackupPanel from '@/components/system/DatabaseBackupPanel.vue'
import { copyToClipboard } from '@/@core/utils/navigator'
import { useI18n } from 'vue-i18n'
import { downloaderOptions, mediaServerOptions } from '@/api/constants'
@@ -2485,7 +2486,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
<VWindowItem value="data">
<div>
<VRow>
<VCol cols="12">
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.DB_BACKUP_ENABLE"
:label="t('setting.system.dbBackupEnable')"
@@ -2494,6 +2495,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
/>
</VCol>
<template v-if="SystemSettings.Advanced.DB_BACKUP_ENABLE">
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.DB_BACKUP_ON_UPGRADE"
:label="t('setting.system.dbBackupOnUpgrade')"
:hint="t('setting.system.dbBackupOnUpgradeHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VCronField
v-model="SystemSettings.Advanced.DB_BACKUP_CRON"
@@ -2543,15 +2552,10 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
prepend-inner-icon="mdi-backup-restore"
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="SystemSettings.Advanced.DB_BACKUP_ON_UPGRADE"
:label="t('setting.system.dbBackupOnUpgrade')"
:hint="t('setting.system.dbBackupOnUpgradeHint')"
persistent-hint
/>
</VCol>
</template>
<VCol cols="12">
<DatabaseBackupPanel :active="activeTab === 'data'" />
</VCol>
<VCol cols="12">
<VSwitch
v-model="SystemSettings.Advanced.DATA_CLEANUP_ENABLE"
@@ -242,6 +242,7 @@ const BASIC_SETTING_KEYS = [
function mockLoadedSettings() {
mocks.apiGet.mockImplementation((endpoint: string) => {
if (endpoint === 'system/env') return { success: true, data: systemEnv }
if (endpoint === 'system/database/backups') return { success: true, data: [] }
if (endpoint === 'message/agent/mcp/servers') return { success: true, data: { servers: [] } }
if (endpoint === 'system/setting/Downloaders')
return { success: true, data: { value: structuredClone(downloadersSetting) } }
@@ -1135,7 +1136,7 @@ describe('AccountSettingSystem', () => {
expect(dialog.getByLabelText('备份目录')).toHaveAttribute('data-storage', 'local')
expect(dialog.getByLabelText('备份过期天数')).toHaveValue(30)
expect(dialog.getByLabelText('最大保留份数')).toHaveValue(30)
expect(dialog.getByLabelText('数据库迁移前备份')).toBeChecked()
expect(dialog.getByLabelText('升级前自动备份')).toBeChecked()
})
it('loads, edits, and saves the database backup policy', async () => {
@@ -1153,12 +1154,12 @@ describe('AccountSettingSystem', () => {
expect(dialog.getByLabelText('备份周期')).toHaveValue('15 2 * * 1')
expect(dialog.getByLabelText('备份目录')).toHaveValue('/data/backup')
expect(dialog.getByLabelText('数据库迁移前备份')).not.toBeChecked()
expect(dialog.getByLabelText('升级前自动备份')).not.toBeChecked()
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.getByLabelText('数据库迁移前备份'))
await fireEvent.click(dialog.getByLabelText('升级前自动备份'))
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
@@ -1189,7 +1190,7 @@ describe('AccountSettingSystem', () => {
await fireEvent.click(dialog.getByLabelText('启用数据备份'))
expect(dialog.queryByLabelText('备份周期')).not.toBeInTheDocument()
expect(dialog.queryByLabelText('数据库迁移前备份')).not.toBeInTheDocument()
expect(dialog.queryByLabelText('升级前自动备份')).not.toBeInTheDocument()
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())