mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-30 20:54:58 +08:00
feat: 完善数据库备份管理界面 (#717)
* feat(settings): add database backup management * feat(settings): complete database backup controls
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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: '整理歷史表保留天數',
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user