From 94eac9820737d2c76bc93574bdf158a4eb03d72b Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:00:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E5=A4=87=E4=BB=BD=E7=AE=A1=E7=90=86=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): add database backup management * feat(settings): complete database backup controls --- src/api/databaseBackup.ts | 37 +++ src/components/system/DatabaseBackupPanel.vue | 252 ++++++++++++++++++ .../__tests__/DatabaseBackupPanel.spec.ts | 139 ++++++++++ src/locales/en-US.ts | 36 ++- src/locales/zh-CN.ts | 34 ++- src/locales/zh-TW.ts | 34 ++- src/views/setting/AccountSettingSystem.vue | 22 +- .../__tests__/AccountSettingSystem.spec.ts | 9 +- 8 files changed, 533 insertions(+), 30 deletions(-) create mode 100644 src/api/databaseBackup.ts create mode 100644 src/components/system/DatabaseBackupPanel.vue create mode 100644 src/components/system/__tests__/DatabaseBackupPanel.spec.ts diff --git a/src/api/databaseBackup.ts b/src/api/databaseBackup.ts new file mode 100644 index 00000000..81a58b1f --- /dev/null +++ b/src/api/databaseBackup.ts @@ -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 { + return api.get('system/database/backups', { feedback: 'silent' }) +} + +/** 创建、校验并发布当前活动数据库的一致快照。 */ +export function createDatabaseBackup(): Promise { + return api.post('system/database/backups', undefined, { feedback: 'silent' }) +} + +/** 重新校验指定受管备份。 */ +export function verifyDatabaseBackup(name: string): Promise { + return api.post(`system/database/backups/${encodeURIComponent(name)}/verify`, undefined, { + feedback: 'silent', + }) +} + +/** 删除指定受管备份文件。 */ +export function deleteDatabaseBackup(name: string): Promise { + return api.delete(`system/database/backups/${encodeURIComponent(name)}`, { feedback: 'silent' }) +} diff --git a/src/components/system/DatabaseBackupPanel.vue b/src/components/system/DatabaseBackupPanel.vue new file mode 100644 index 00000000..e17f6901 --- /dev/null +++ b/src/components/system/DatabaseBackupPanel.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/src/components/system/__tests__/DatabaseBackupPanel.spec.ts b/src/components/system/__tests__/DatabaseBackupPanel.spec.ts new file mode 100644 index 00000000..2501778b --- /dev/null +++ b/src/components/system/__tests__/DatabaseBackupPanel.spec.ts @@ -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) + }) +}) diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 4c424bce..a016e789 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -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', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index a593d537..9320c58e 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -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: '整理历史表保留天数', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index a85e0c2e..63723f9b 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -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: '整理歷史表保留天數', diff --git a/src/views/setting/AccountSettingSystem.vue b/src/views/setting/AccountSettingSystem.vue index d9db16dc..c4bc1232 100644 --- a/src/views/setting/AccountSettingSystem.vue +++ b/src/views/setting/AccountSettingSystem.vue @@ -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) => {
- + { /> + + + { 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())