Merge pull request #966 from GuangYiDing/feat/db-backup-restore

feat: 数据库定时备份与恢复(S3 兼容存储,支持 Cloudflare R2)
This commit is contained in:
Wesley Liddick
2026-03-14 18:58:56 +08:00
committed by GitHub
22 changed files with 2784 additions and 5 deletions

View File

@@ -0,0 +1,114 @@
import { apiClient } from '../client'
export interface BackupS3Config {
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key?: string
prefix: string
force_path_style: boolean
}
export interface BackupScheduleConfig {
enabled: boolean
cron_expr: string
retain_days: number
retain_count: number
}
export interface BackupRecord {
id: string
status: 'pending' | 'running' | 'completed' | 'failed'
backup_type: string
file_name: string
s3_key: string
size_bytes: number
triggered_by: string
error_message?: string
started_at: string
finished_at?: string
expires_at?: string
}
export interface CreateBackupRequest {
expire_days?: number
}
export interface TestS3Response {
ok: boolean
message: string
}
// S3 Config
export async function getS3Config(): Promise<BackupS3Config> {
const { data } = await apiClient.get<BackupS3Config>('/admin/backups/s3-config')
return data
}
export async function updateS3Config(config: BackupS3Config): Promise<BackupS3Config> {
const { data } = await apiClient.put<BackupS3Config>('/admin/backups/s3-config', config)
return data
}
export async function testS3Connection(config: BackupS3Config): Promise<TestS3Response> {
const { data } = await apiClient.post<TestS3Response>('/admin/backups/s3-config/test', config)
return data
}
// Schedule
export async function getSchedule(): Promise<BackupScheduleConfig> {
const { data } = await apiClient.get<BackupScheduleConfig>('/admin/backups/schedule')
return data
}
export async function updateSchedule(config: BackupScheduleConfig): Promise<BackupScheduleConfig> {
const { data } = await apiClient.put<BackupScheduleConfig>('/admin/backups/schedule', config)
return data
}
// Backup operations
export async function createBackup(req?: CreateBackupRequest): Promise<BackupRecord> {
const { data } = await apiClient.post<BackupRecord>('/admin/backups', req || {}, { timeout: 600000 })
return data
}
export async function listBackups(): Promise<{ items: BackupRecord[] }> {
const { data } = await apiClient.get<{ items: BackupRecord[] }>('/admin/backups')
return data
}
export async function getBackup(id: string): Promise<BackupRecord> {
const { data } = await apiClient.get<BackupRecord>(`/admin/backups/${id}`)
return data
}
export async function deleteBackup(id: string): Promise<void> {
await apiClient.delete(`/admin/backups/${id}`)
}
export async function getDownloadURL(id: string): Promise<{ url: string }> {
const { data } = await apiClient.get<{ url: string }>(`/admin/backups/${id}/download-url`)
return data
}
// Restore
export async function restoreBackup(id: string, password: string): Promise<void> {
await apiClient.post(`/admin/backups/${id}/restore`, { password }, { timeout: 600000 })
}
export const backupAPI = {
getS3Config,
updateS3Config,
testS3Connection,
getSchedule,
updateSchedule,
createBackup,
listBackups,
getBackup,
deleteBackup,
getDownloadURL,
restoreBackup,
}
export default backupAPI

View File

@@ -23,6 +23,7 @@ import errorPassthroughAPI from './errorPassthrough'
import dataManagementAPI from './dataManagement'
import apiKeysAPI from './apiKeys'
import scheduledTestsAPI from './scheduledTests'
import backupAPI from './backup'
/**
* Unified admin API object for convenient access
@@ -47,7 +48,8 @@ export const adminAPI = {
errorPassthrough: errorPassthroughAPI,
dataManagement: dataManagementAPI,
apiKeys: apiKeysAPI,
scheduledTests: scheduledTestsAPI
scheduledTests: scheduledTestsAPI,
backup: backupAPI
}
export {
@@ -70,7 +72,8 @@ export {
errorPassthroughAPI,
dataManagementAPI,
apiKeysAPI,
scheduledTestsAPI
scheduledTestsAPI,
backupAPI
}
export default adminAPI

View File

@@ -387,6 +387,21 @@ const DatabaseIcon = {
)
}
const CloudArrowUpIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M12 16.5V9.75m0 0l3 3m-3-3l-3 3M6.75 19.5a4.5 4.5 0 01-1.41-8.775 5.25 5.25 0 0110.233-2.33 3 3 0 013.758 3.848A3.752 3.752 0 0118 19.5H6.75z'
})
]
)
}
const BellIcon = {
render: () =>
h(
@@ -611,6 +626,7 @@ const adminNavItems = computed((): NavItem[] => {
if (authStore.isSimpleMode) {
const filtered = baseItems.filter(item => !item.hideInSimpleMode)
filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon })
filtered.push({ path: '/admin/backup', label: t('nav.backup'), icon: CloudArrowUpIcon })
filtered.push({ path: '/admin/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon })
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
// Add admin custom menu items after settings
@@ -620,6 +636,7 @@ const adminNavItems = computed((): NavItem[] => {
return filtered
}
baseItems.push({ path: '/admin/backup', label: t('nav.backup'), icon: CloudArrowUpIcon })
baseItems.push({ path: '/admin/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon })
baseItems.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
// Add admin custom menu items after settings

View File

@@ -340,6 +340,7 @@ export default {
redeemCodes: 'Redeem Codes',
ops: 'Ops',
promoCodes: 'Promo Codes',
backup: 'DB Backup',
dataManagement: 'Data Management',
settings: 'Settings',
myAccount: 'My Account',
@@ -978,6 +979,111 @@ export default {
failedToLoad: 'Failed to load dashboard statistics'
},
backup: {
title: 'Database Backup',
description: 'Full database backup to S3-compatible storage with scheduled backup and restore',
s3: {
title: 'S3 Storage Configuration',
description: 'Configure S3-compatible storage (supports Cloudflare R2)',
descriptionPrefix: 'Configure S3-compatible storage (supports',
descriptionSuffix: ')',
enabled: 'Enable S3 Storage',
endpoint: 'Endpoint',
region: 'Region',
bucket: 'Bucket',
prefix: 'Key Prefix',
accessKeyId: 'Access Key ID',
secretAccessKey: 'Secret Access Key',
secretConfigured: 'Already configured, leave empty to keep',
forcePathStyle: 'Force Path Style',
testConnection: 'Test Connection',
testSuccess: 'S3 connection test successful',
testFailed: 'S3 connection test failed',
saved: 'S3 configuration saved'
},
schedule: {
title: 'Scheduled Backup',
description: 'Configure automatic scheduled backups',
enabled: 'Enable Scheduled Backup',
cronExpr: 'Cron Expression',
cronHint: 'e.g. "0 2 * * *" means every day at 2:00 AM',
retainDays: 'Backup Expire Days',
retainDaysHint: 'Backup files auto-delete after this many days, 0 = never expire',
retainCount: 'Max Retain Count',
retainCountHint: 'Maximum number of backups to keep, 0 = unlimited',
saved: 'Schedule configuration saved'
},
operations: {
title: 'Backup Records',
description: 'Create manual backups and manage existing backup records',
createBackup: 'Create Backup',
backing: 'Backing up...',
backupCreated: 'Backup created successfully',
expireDays: 'Expire Days'
},
columns: {
status: 'Status',
fileName: 'File Name',
size: 'Size',
expiresAt: 'Expires At',
triggeredBy: 'Triggered By',
startedAt: 'Started At',
actions: 'Actions'
},
status: {
pending: 'Pending',
running: 'Running',
completed: 'Completed',
failed: 'Failed'
},
trigger: {
manual: 'Manual',
scheduled: 'Scheduled'
},
neverExpire: 'Never',
empty: 'No backup records',
actions: {
download: 'Download',
restore: 'Restore',
restoreConfirm: 'Are you sure you want to restore from this backup? This will overwrite the current database!',
restorePasswordPrompt: 'Please enter your admin password to confirm the restore operation',
restoreSuccess: 'Database restored successfully',
deleteConfirm: 'Are you sure you want to delete this backup?',
deleted: 'Backup deleted'
},
r2Guide: {
title: 'Cloudflare R2 Setup Guide',
intro: 'Cloudflare R2 provides S3-compatible object storage with a free tier of 10GB storage + 1M Class A requests/month, ideal for database backups.',
step1: {
title: 'Create an R2 Bucket',
line1: 'Log in to the Cloudflare Dashboard (dash.cloudflare.com), select "R2 Object Storage" from the sidebar',
line2: 'Click "Create bucket", enter a name (e.g. sub2api-backups), choose a region',
line3: 'Click create to finish'
},
step2: {
title: 'Create an API Token',
line1: 'On the R2 page, click "Manage R2 API Tokens" in the top right',
line2: 'Click "Create API token", set permission to "Object Read & Write"',
line3: 'Recommended: restrict to specific bucket for better security',
line4: 'After creation, you will see the Access Key ID and Secret Access Key',
warning: 'The Secret Access Key is only shown once — copy and save it immediately!'
},
step3: {
title: 'Get the S3 Endpoint',
desc: 'Find your Account ID on the R2 overview page (in the URL or the right panel). The endpoint format is:',
accountId: 'your_account_id'
},
step4: {
title: 'Fill in the Configuration',
checkEnabled: 'Checked',
bucketValue: 'Your bucket name',
fromStep2: 'Value from Step 2',
unchecked: 'Unchecked'
},
freeTier: 'R2 Free Tier: 10GB storage + 1M Class A requests + 10M Class B requests per month — more than enough for database backups.'
}
},
dataManagement: {
title: 'Data Management',
description: 'Manage data management agent status, object storage settings, and backup jobs in one place',

View File

@@ -340,6 +340,7 @@ export default {
redeemCodes: '兑换码',
ops: '运维监控',
promoCodes: '优惠码',
backup: '数据库备份',
dataManagement: '数据管理',
settings: '系统设置',
myAccount: '我的账户',
@@ -1000,6 +1001,111 @@ export default {
failedToLoad: '加载仪表盘数据失败'
},
backup: {
title: '数据库备份',
description: '全量数据库备份到 S3 兼容存储,支持定时备份与恢复',
s3: {
title: 'S3 存储配置',
description: '配置 S3 兼容存储(支持 Cloudflare R2',
descriptionPrefix: '配置 S3 兼容存储(支持',
descriptionSuffix: '',
enabled: '启用 S3 存储',
endpoint: '端点地址',
region: '区域',
bucket: '存储桶',
prefix: 'Key 前缀',
accessKeyId: 'Access Key ID',
secretAccessKey: 'Secret Access Key',
secretConfigured: '已配置,留空保持不变',
forcePathStyle: '强制路径风格',
testConnection: '测试连接',
testSuccess: 'S3 连接测试成功',
testFailed: 'S3 连接测试失败',
saved: 'S3 配置已保存'
},
schedule: {
title: '定时备份',
description: '配置自动定时备份',
enabled: '启用定时备份',
cronExpr: 'Cron 表达式',
cronHint: '例如 "0 2 * * *" 表示每天凌晨 2 点',
retainDays: '备份过期天数',
retainDaysHint: '备份文件超过此天数后自动删除0 = 永不过期',
retainCount: '最大保留份数',
retainCountHint: '最多保留的备份数量0 = 不限制',
saved: '定时备份配置已保存'
},
operations: {
title: '备份记录',
description: '创建手动备份和管理已有备份记录',
createBackup: '创建备份',
backing: '备份中...',
backupCreated: '备份创建成功',
expireDays: '过期天数'
},
columns: {
status: '状态',
fileName: '文件名',
size: '大小',
expiresAt: '过期时间',
triggeredBy: '触发方式',
startedAt: '开始时间',
actions: '操作'
},
status: {
pending: '等待中',
running: '执行中',
completed: '已完成',
failed: '失败'
},
trigger: {
manual: '手动',
scheduled: '定时'
},
neverExpire: '永不过期',
empty: '暂无备份记录',
actions: {
download: '下载',
restore: '恢复',
restoreConfirm: '确定要从此备份恢复吗?这将覆盖当前数据库!',
restorePasswordPrompt: '请输入管理员密码以确认恢复操作',
restoreSuccess: '数据库恢复成功',
deleteConfirm: '确定要删除此备份吗?',
deleted: '备份已删除'
},
r2Guide: {
title: 'Cloudflare R2 配置教程',
intro: 'Cloudflare R2 提供 S3 兼容的对象存储,免费额度为 10GB 存储 + 每月 100 万次 A 类请求,非常适合数据库备份。',
step1: {
title: '创建 R2 存储桶',
line1: '登录 Cloudflare Dashboard (dash.cloudflare.com)左侧菜单选择「R2 对象存储」',
line2: '点击「创建存储桶」,输入名称(如 sub2api-backups选择区域',
line3: '点击创建完成'
},
step2: {
title: '创建 API 令牌',
line1: '在 R2 页面,点击右上角「管理 R2 API 令牌」',
line2: '点击「创建 API 令牌」,权限选择「对象读和写」',
line3: '建议指定存储桶范围(仅允许访问备份桶,更安全)',
line4: '创建后会显示 Access Key ID 和 Secret Access Key',
warning: 'Secret Access Key 只会显示一次,请立即复制保存!'
},
step3: {
title: '获取 S3 端点地址',
desc: '在 R2 概览页面找到你的账户 ID在 URL 或右侧面板中),端点格式为:',
accountId: '你的账户 ID'
},
step4: {
title: '填写以下配置',
checkEnabled: '勾选',
bucketValue: '你创建的存储桶名称',
fromStep2: '第 2 步获取的值',
unchecked: '不勾选'
},
freeTier: 'R2 免费额度10GB 存储 + 每月 100 万次 A 类请求 + 1000 万次 B 类请求,对数据库备份完全够用。'
}
},
dataManagement: {
title: '数据管理',
description: '统一管理数据管理代理状态、对象存储配置和备份任务',

View File

@@ -350,6 +350,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.promo.description'
}
},
{
path: '/admin/backup',
name: 'AdminBackup',
component: () => import('@/views/admin/BackupView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Database Backup',
titleKey: 'admin.backup.title',
descriptionKey: 'admin.backup.description'
}
},
{
path: '/admin/data-management',
name: 'AdminDataManagement',

View File

@@ -0,0 +1,508 @@
<template>
<AppLayout>
<div class="space-y-6">
<!-- S3 Storage Config -->
<div class="card p-6">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ t('admin.backup.s3.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.backup.s3.descriptionPrefix') }}
<button type="button" class="text-primary-600 underline hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" @click="showR2Guide = true">Cloudflare R2</button>
{{ t('admin.backup.s3.descriptionSuffix') }}
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.endpoint') }}</label>
<input v-model="s3Form.endpoint" class="input w-full" placeholder="https://<account_id>.r2.cloudflarestorage.com" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.region') }}</label>
<input v-model="s3Form.region" class="input w-full" placeholder="auto" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.bucket') }}</label>
<input v-model="s3Form.bucket" class="input w-full" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.prefix') }}</label>
<input v-model="s3Form.prefix" class="input w-full" placeholder="backups/" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.accessKeyId') }}</label>
<input v-model="s3Form.access_key_id" class="input w-full" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.secretAccessKey') }}</label>
<input v-model="s3Form.secret_access_key" type="password" class="input w-full" :placeholder="s3SecretConfigured ? t('admin.backup.s3.secretConfigured') : ''" />
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2">
<input v-model="s3Form.force_path_style" type="checkbox" />
<span>{{ t('admin.backup.s3.forcePathStyle') }}</span>
</label>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button type="button" class="btn btn-secondary btn-sm" :disabled="testingS3" @click="testS3">
{{ testingS3 ? t('common.loading') : t('admin.backup.s3.testConnection') }}
</button>
<button type="button" class="btn btn-primary btn-sm" :disabled="savingS3" @click="saveS3Config">
{{ savingS3 ? t('common.loading') : t('common.save') }}
</button>
</div>
</div>
<!-- Schedule Config -->
<div class="card p-6">
<div class="mb-4">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ t('admin.backup.schedule.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.backup.schedule.description') }}
</p>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2">
<input v-model="scheduleForm.enabled" type="checkbox" />
<span>{{ t('admin.backup.schedule.enabled') }}</span>
</label>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.schedule.cronExpr') }}</label>
<input v-model="scheduleForm.cron_expr" class="input w-full" placeholder="0 2 * * *" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.backup.schedule.cronHint') }}</p>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.schedule.retainDays') }}</label>
<input v-model.number="scheduleForm.retain_days" type="number" min="0" class="input w-full" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.backup.schedule.retainDaysHint') }}</p>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.schedule.retainCount') }}</label>
<input v-model.number="scheduleForm.retain_count" type="number" min="0" class="input w-full" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.backup.schedule.retainCountHint') }}</p>
</div>
</div>
<div class="mt-4">
<button type="button" class="btn btn-primary btn-sm" :disabled="savingSchedule" @click="saveSchedule">
{{ savingSchedule ? t('common.loading') : t('common.save') }}
</button>
</div>
</div>
<!-- Backup Operations -->
<div class="card p-6">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ t('admin.backup.operations.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.backup.operations.description') }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="flex items-center gap-1">
<label class="text-xs text-gray-600 dark:text-gray-400">{{ t('admin.backup.operations.expireDays') }}</label>
<input v-model.number="manualExpireDays" type="number" min="0" class="input w-20 text-xs" />
</div>
<button type="button" class="btn btn-primary btn-sm" :disabled="creatingBackup" @click="createBackup">
{{ creatingBackup ? t('admin.backup.operations.backing') : t('admin.backup.operations.createBackup') }}
</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="loadingBackups" @click="loadBackups">
{{ loadingBackups ? t('common.loading') : t('common.refresh') }}
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full min-w-[800px] text-sm">
<thead>
<tr class="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:text-gray-400">
<th class="py-2 pr-4">ID</th>
<th class="py-2 pr-4">{{ t('admin.backup.columns.status') }}</th>
<th class="py-2 pr-4">{{ t('admin.backup.columns.fileName') }}</th>
<th class="py-2 pr-4">{{ t('admin.backup.columns.size') }}</th>
<th class="py-2 pr-4">{{ t('admin.backup.columns.expiresAt') }}</th>
<th class="py-2 pr-4">{{ t('admin.backup.columns.triggeredBy') }}</th>
<th class="py-2 pr-4">{{ t('admin.backup.columns.startedAt') }}</th>
<th class="py-2">{{ t('admin.backup.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="record in backups" :key="record.id" class="border-b border-gray-100 align-top dark:border-dark-800">
<td class="py-3 pr-4 font-mono text-xs">{{ record.id }}</td>
<td class="py-3 pr-4">
<span
class="rounded px-2 py-0.5 text-xs"
:class="statusClass(record.status)"
>
{{ t(`admin.backup.status.${record.status}`) }}
</span>
</td>
<td class="py-3 pr-4 text-xs">{{ record.file_name }}</td>
<td class="py-3 pr-4 text-xs">{{ formatSize(record.size_bytes) }}</td>
<td class="py-3 pr-4 text-xs">
{{ record.expires_at ? formatDate(record.expires_at) : t('admin.backup.neverExpire') }}
</td>
<td class="py-3 pr-4 text-xs">
{{ record.triggered_by === 'scheduled' ? t('admin.backup.trigger.scheduled') : t('admin.backup.trigger.manual') }}
</td>
<td class="py-3 pr-4 text-xs">{{ formatDate(record.started_at) }}</td>
<td class="py-3 text-xs">
<div class="flex flex-wrap gap-1">
<button
v-if="record.status === 'completed'"
type="button"
class="btn btn-secondary btn-xs"
@click="downloadBackup(record.id)"
>
{{ t('admin.backup.actions.download') }}
</button>
<button
v-if="record.status === 'completed'"
type="button"
class="btn btn-secondary btn-xs"
:disabled="restoringId === record.id"
@click="restoreBackup(record.id)"
>
{{ restoringId === record.id ? t('common.loading') : t('admin.backup.actions.restore') }}
</button>
<button
type="button"
class="btn btn-danger btn-xs"
@click="removeBackup(record.id)"
>
{{ t('common.delete') }}
</button>
</div>
</td>
</tr>
<tr v-if="backups.length === 0">
<td colspan="8" class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.backup.empty') }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Cloudflare R2 Setup Guide Modal -->
<teleport to="body">
<transition name="modal">
<div v-if="showR2Guide" class="fixed inset-0 z-50 flex items-center justify-center p-4" @mousedown.self="showR2Guide = false">
<div class="fixed inset-0 bg-black/50" @click="showR2Guide = false"></div>
<div class="relative max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl dark:bg-dark-800">
<button type="button" class="absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" @click="showR2Guide = false">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
<h2 class="mb-4 text-lg font-bold text-gray-900 dark:text-white">{{ t('admin.backup.r2Guide.title') }}</h2>
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.backup.r2Guide.intro') }}</p>
<!-- Step 1 -->
<div class="mb-5">
<h3 class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">1</span>
{{ t('admin.backup.r2Guide.step1.title') }}
</h3>
<ol class="ml-8 list-decimal space-y-1 text-sm text-gray-600 dark:text-gray-300">
<li>{{ t('admin.backup.r2Guide.step1.line1') }}</li>
<li>{{ t('admin.backup.r2Guide.step1.line2') }}</li>
<li>{{ t('admin.backup.r2Guide.step1.line3') }}</li>
</ol>
</div>
<!-- Step 2 -->
<div class="mb-5">
<h3 class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">2</span>
{{ t('admin.backup.r2Guide.step2.title') }}
</h3>
<ol class="ml-8 list-decimal space-y-1 text-sm text-gray-600 dark:text-gray-300">
<li>{{ t('admin.backup.r2Guide.step2.line1') }}</li>
<li>{{ t('admin.backup.r2Guide.step2.line2') }}</li>
<li>{{ t('admin.backup.r2Guide.step2.line3') }}</li>
<li>{{ t('admin.backup.r2Guide.step2.line4') }}</li>
</ol>
<div class="mt-2 rounded-lg bg-amber-50 p-3 text-xs text-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
{{ t('admin.backup.r2Guide.step2.warning') }}
</div>
</div>
<!-- Step 3 -->
<div class="mb-5">
<h3 class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">3</span>
{{ t('admin.backup.r2Guide.step3.title') }}
</h3>
<p class="ml-8 text-sm text-gray-600 dark:text-gray-300">{{ t('admin.backup.r2Guide.step3.desc') }}</p>
<code class="ml-8 mt-1 block rounded bg-gray-100 px-3 py-2 text-xs text-gray-800 dark:bg-dark-700 dark:text-gray-200">https://&lt;{{ t('admin.backup.r2Guide.step3.accountId') }}&gt;.r2.cloudflarestorage.com</code>
</div>
<!-- Step 4: Fill form -->
<div class="mb-5">
<h3 class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">4</span>
{{ t('admin.backup.r2Guide.step4.title') }}
</h3>
<div class="ml-8 overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600">
<table class="w-full text-sm">
<tbody>
<tr v-for="(row, i) in r2ConfigRows" :key="i" class="border-b border-gray-100 dark:border-dark-700 last:border-0">
<td class="whitespace-nowrap bg-gray-50 px-3 py-2 font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-300">{{ row.field }}</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400"><code class="text-xs">{{ row.value }}</code></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Free tier note -->
<div class="rounded-lg bg-green-50 p-3 text-xs text-green-700 dark:bg-green-900/20 dark:text-green-300">
{{ t('admin.backup.r2Guide.freeTier') }}
</div>
<div class="mt-4 text-right">
<button type="button" class="btn btn-primary btn-sm" @click="showR2Guide = false">{{ t('common.close') }}</button>
</div>
</div>
</div>
</transition>
</teleport>
</AppLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AppLayout from '@/components/layout/AppLayout.vue'
import { adminAPI } from '@/api'
import { useAppStore } from '@/stores'
import type { BackupS3Config, BackupScheduleConfig, BackupRecord } from '@/api/admin/backup'
const { t } = useI18n()
const appStore = useAppStore()
// S3 config
const s3Form = ref<BackupS3Config>({
endpoint: '',
region: 'auto',
bucket: '',
access_key_id: '',
secret_access_key: '',
prefix: 'backups/',
force_path_style: false,
})
const s3SecretConfigured = ref(false)
const savingS3 = ref(false)
const testingS3 = ref(false)
// Schedule config
const scheduleForm = ref<BackupScheduleConfig>({
enabled: false,
cron_expr: '0 2 * * *',
retain_days: 14,
retain_count: 10,
})
const savingSchedule = ref(false)
// Backups
const backups = ref<BackupRecord[]>([])
const loadingBackups = ref(false)
const creatingBackup = ref(false)
const restoringId = ref('')
const manualExpireDays = ref(14)
// R2 guide
const showR2Guide = ref(false)
const r2ConfigRows = computed(() => [
{ field: t('admin.backup.s3.endpoint'), value: 'https://<account_id>.r2.cloudflarestorage.com' },
{ field: t('admin.backup.s3.region'), value: 'auto' },
{ field: t('admin.backup.s3.bucket'), value: t('admin.backup.r2Guide.step4.bucketValue') },
{ field: t('admin.backup.s3.prefix'), value: 'backups/' },
{ field: 'Access Key ID', value: t('admin.backup.r2Guide.step4.fromStep2') },
{ field: 'Secret Access Key', value: t('admin.backup.r2Guide.step4.fromStep2') },
{ field: t('admin.backup.s3.forcePathStyle'), value: t('admin.backup.r2Guide.step4.unchecked') },
])
async function loadS3Config() {
try {
const cfg = await adminAPI.backup.getS3Config()
s3Form.value = {
endpoint: cfg.endpoint || '',
region: cfg.region || 'auto',
bucket: cfg.bucket || '',
access_key_id: cfg.access_key_id || '',
secret_access_key: '',
prefix: cfg.prefix || 'backups/',
force_path_style: cfg.force_path_style,
}
s3SecretConfigured.value = Boolean(cfg.access_key_id)
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
}
}
async function saveS3Config() {
savingS3.value = true
try {
await adminAPI.backup.updateS3Config(s3Form.value)
appStore.showSuccess(t('admin.backup.s3.saved'))
await loadS3Config()
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
savingS3.value = false
}
}
async function testS3() {
testingS3.value = true
try {
const result = await adminAPI.backup.testS3Connection(s3Form.value)
if (result.ok) {
appStore.showSuccess(result.message || t('admin.backup.s3.testSuccess'))
} else {
appStore.showError(result.message || t('admin.backup.s3.testFailed'))
}
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
testingS3.value = false
}
}
async function loadSchedule() {
try {
const cfg = await adminAPI.backup.getSchedule()
scheduleForm.value = {
enabled: cfg.enabled,
cron_expr: cfg.cron_expr || '0 2 * * *',
retain_days: cfg.retain_days || 14,
retain_count: cfg.retain_count || 10,
}
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
}
}
async function saveSchedule() {
savingSchedule.value = true
try {
await adminAPI.backup.updateSchedule(scheduleForm.value)
appStore.showSuccess(t('admin.backup.schedule.saved'))
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
savingSchedule.value = false
}
}
async function loadBackups() {
loadingBackups.value = true
try {
const result = await adminAPI.backup.listBackups()
backups.value = result.items || []
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
loadingBackups.value = false
}
}
async function createBackup() {
creatingBackup.value = true
try {
await adminAPI.backup.createBackup({ expire_days: manualExpireDays.value })
appStore.showSuccess(t('admin.backup.operations.backupCreated'))
await loadBackups()
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
creatingBackup.value = false
}
}
async function downloadBackup(id: string) {
try {
const result = await adminAPI.backup.getDownloadURL(id)
window.open(result.url, '_blank')
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
}
}
async function restoreBackup(id: string) {
if (!window.confirm(t('admin.backup.actions.restoreConfirm'))) return
const password = window.prompt(t('admin.backup.actions.restorePasswordPrompt'))
if (!password) return
restoringId.value = id
try {
await adminAPI.backup.restoreBackup(id, password)
appStore.showSuccess(t('admin.backup.actions.restoreSuccess'))
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
restoringId.value = ''
}
}
async function removeBackup(id: string) {
if (!window.confirm(t('admin.backup.actions.deleteConfirm'))) return
try {
await adminAPI.backup.deleteBackup(id)
appStore.showSuccess(t('admin.backup.actions.deleted'))
await loadBackups()
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
}
}
function statusClass(status: string): string {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
case 'running':
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
case 'failed':
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
default:
return 'bg-gray-100 text-gray-700 dark:bg-dark-800 dark:text-gray-300'
}
}
function formatSize(bytes: number): string {
if (!bytes || bytes <= 0) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function formatDate(value?: string): string {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
onMounted(async () => {
await Promise.all([loadS3Config(), loadSchedule(), loadBackups()])
})
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>