feat(admin): add configurable S3 backups

This commit is contained in:
Kayphoon
2026-05-26 14:41:56 +08:00
parent c4927162b7
commit 84c8bc960e
26 changed files with 4426 additions and 5 deletions
@@ -46,6 +46,9 @@
@file-select="handleDataFileSelect"
/>
<!-- S3 备份 -->
<S3BackupSection id="section-s3-backup" />
<!-- 网络代理 -->
<ProxyConfigSection
id="section-proxy"
@@ -267,6 +270,7 @@ import { useScheduledTasks } from './system-settings/composables/useScheduledTas
// Section components
import SiteInfoSection from './system-settings/SiteInfoSection.vue'
import DataManagementSection from './system-settings/DataManagementSection.vue'
import S3BackupSection from './system-settings/S3BackupSection.vue'
import ProxyConfigSection from './system-settings/ProxyConfigSection.vue'
import BasicConfigSection from './system-settings/BasicConfigSection.vue'
import RequestLogSection from './system-settings/RequestLogSection.vue'
@@ -285,6 +289,7 @@ const proxyNodesStore = useProxyNodesStore()
const tocItems = [
{ id: 'section-site-info', label: '站点信息' },
{ id: 'section-data-mgmt', label: '数据管理' },
{ id: 'section-s3-backup', label: 'S3 备份' },
{ id: 'section-proxy', label: '网络代理' },
{ id: 'section-basic', label: '基础配置' },
{ id: 'section-request-log', label: '请求记录' },
@@ -0,0 +1,476 @@
<template>
<CardSection
title="S3 备份"
description="按导出范围备份到 S3-compatible 存储"
>
<template #actions>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
:disabled="backupRunDisabled"
@click="backup.runS3BackupNow"
>
<Play class="w-3.5 h-3.5 mr-1.5" />
{{ backup.running.value ? '提交中...' : '立即备份' }}
</Button>
<Button
size="sm"
:disabled="backup.saving.value || !backup.hasChanges.value"
@click="backup.saveS3BackupConfig"
>
<Save class="w-3.5 h-3.5 mr-1.5" />
{{ backup.saving.value ? '保存中...' : '保存' }}
</Button>
</div>
</template>
<div class="space-y-5">
<div class="flex items-center justify-between rounded-lg border border-border p-4">
<div class="flex items-center gap-3 min-w-0">
<div class="w-9 h-9 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
<CloudUpload class="w-4.5 h-4.5" />
</div>
<div class="min-w-0">
<h4 class="text-sm font-medium">
自动备份
</h4>
<p class="text-xs text-muted-foreground mt-0.5">
{{ backup.config.value.enabled ? '已启用' : '未启用' }}
</p>
</div>
</div>
<Switch
:model-value="backup.config.value.enabled"
@update:model-value="backup.config.value.enabled = $event"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label
for="backup-scope"
class="block text-sm font-medium"
>
备份范围
</Label>
<Select
:model-value="backup.config.value.scope"
@update:model-value="setScope"
>
<SelectTrigger
id="backup-scope"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="config">
配置数据
</SelectItem>
<SelectItem value="users">
用户数据
</SelectItem>
<SelectItem value="data">
完整备份
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label
for="backup-bucket"
class="block text-sm font-medium"
>
Bucket
</Label>
<Input
id="backup-bucket"
:model-value="backup.config.value.bucket"
class="mt-1"
placeholder="aether-backups"
@update:model-value="backup.config.value.bucket = String($event)"
/>
</div>
<div>
<Label
for="backup-endpoint"
class="block text-sm font-medium"
>
Endpoint
</Label>
<Input
id="backup-endpoint"
:model-value="backup.config.value.endpoint"
class="mt-1"
placeholder="https://s3.example.com"
@update:model-value="backup.config.value.endpoint = String($event)"
/>
</div>
<div>
<Label
for="backup-access-key"
class="block text-sm font-medium"
>
Access Key ID
</Label>
<Input
id="backup-access-key"
:model-value="backup.config.value.accessKeyId"
class="mt-1"
autocomplete="off"
@update:model-value="backup.config.value.accessKeyId = String($event)"
/>
</div>
<div>
<div class="flex items-center justify-between gap-2">
<Label
for="backup-secret-key"
class="block text-sm font-medium"
>
Secret Access Key
</Label>
<button
v-if="backup.config.value.secretAccessKeyIsSet"
type="button"
class="text-xs text-muted-foreground hover:text-foreground"
:disabled="backup.saving.value"
@click="backup.clearS3SecretAccessKey"
>
清除
</button>
</div>
<div class="relative mt-1">
<Input
id="backup-secret-key"
:model-value="backup.config.value.secretAccessKey"
masked
disable-autofill
autocomplete="new-password"
:placeholder="backup.config.value.secretAccessKeyIsSet ? '已配置,留空不变' : ''"
@update:model-value="backup.config.value.secretAccessKey = String($event)"
/>
<KeyRound
v-if="backup.config.value.secretAccessKeyIsSet && !backup.config.value.secretAccessKey"
class="absolute right-10 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none"
/>
</div>
</div>
<div>
<Label
for="backup-retention"
class="block text-sm font-medium"
>
最多保留备份数
</Label>
<Input
id="backup-retention"
:model-value="backup.config.value.retentionCount"
type="number"
min="1"
class="mt-1"
@update:model-value="setNumber('retentionCount', $event, 1)"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-[1fr_1fr] gap-4">
<div>
<Label
for="backup-interval"
class="block text-sm font-medium"
>
周期间隔
</Label>
<Input
id="backup-interval"
:model-value="backup.config.value.scheduleInterval"
type="number"
min="1"
class="mt-1"
@update:model-value="setNumber('scheduleInterval', $event, 1)"
/>
</div>
<div>
<Label
for="backup-unit"
class="block text-sm font-medium"
>
周期单位
</Label>
<Select
:model-value="backup.config.value.scheduleUnit"
@update:model-value="setScheduleUnit"
>
<SelectTrigger
id="backup-unit"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hours">
小时
</SelectItem>
<SelectItem value="days">
天
</SelectItem>
<SelectItem value="weeks">
周
</SelectItem>
<SelectItem value="months">
月
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Button
variant="ghost"
size="sm"
class="px-0"
@click="backup.advancedOpen.value = !backup.advancedOpen.value"
>
<ChevronDown
v-if="backup.advancedOpen.value"
class="w-3.5 h-3.5 mr-1.5"
/>
<ChevronRight
v-else
class="w-3.5 h-3.5 mr-1.5"
/>
高级选项
</Button>
<div
v-if="backup.advancedOpen.value"
class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div>
<Label
for="backup-region"
class="block text-sm font-medium"
>
Region
</Label>
<Input
id="backup-region"
:model-value="backup.config.value.region"
class="mt-1"
placeholder="auto"
@update:model-value="backup.config.value.region = String($event)"
/>
</div>
<div>
<Label
for="backup-prefix"
class="block text-sm font-medium"
>
Prefix
</Label>
<Input
id="backup-prefix"
:model-value="backup.config.value.prefix"
class="mt-1"
placeholder="aether/backups/"
@update:model-value="backup.config.value.prefix = String($event)"
/>
</div>
<div>
<Label
for="backup-hour"
class="block text-sm font-medium"
>
执行小时
</Label>
<Input
id="backup-hour"
:model-value="backup.config.value.scheduleHour"
type="number"
min="0"
max="23"
class="mt-1"
@update:model-value="setNumber('scheduleHour', $event, 0)"
/>
</div>
<div>
<Label
for="backup-minute"
class="block text-sm font-medium"
>
执行分钟
</Label>
<Input
id="backup-minute"
:model-value="backup.config.value.scheduleMinute"
type="number"
min="0"
max="59"
class="mt-1"
@update:model-value="setNumber('scheduleMinute', $event, 0)"
/>
</div>
<div v-if="backup.config.value.scheduleUnit === 'weeks'">
<Label
for="backup-weekday"
class="block text-sm font-medium"
>
星期
</Label>
<Select
:model-value="String(backup.config.value.scheduleWeekday)"
@update:model-value="setNumber('scheduleWeekday', $event, 1)"
>
<SelectTrigger
id="backup-weekday"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in weekdays"
:key="item.value"
:value="String(item.value)"
>
{{ item.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="backup.config.value.scheduleUnit === 'months'">
<Label
for="backup-month-day"
class="block text-sm font-medium"
>
每月日期
</Label>
<Input
id="backup-month-day"
:model-value="backup.config.value.scheduleMonthDay"
type="number"
min="1"
max="31"
class="mt-1"
@update:model-value="setNumber('scheduleMonthDay', $event, 1)"
/>
</div>
<div class="flex items-center justify-between rounded-lg border border-border p-4">
<span class="text-sm font-medium">Path Style</span>
<Switch
:model-value="backup.config.value.pathStyle"
@update:model-value="backup.config.value.pathStyle = $event"
/>
</div>
<div>
<Label
for="backup-compression"
class="block text-sm font-medium"
>
压缩格式
</Label>
<Select
:model-value="backup.config.value.compression"
@update:model-value="backup.config.value.compression = $event"
>
<SelectTrigger
id="backup-compression"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="zstd">
zstd
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
</CardSection>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import {
ChevronDown,
ChevronRight,
CloudUpload,
KeyRound,
Play,
Save,
} from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Select from '@/components/ui/select.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import Switch from '@/components/ui/switch.vue'
import { CardSection } from '@/components/layout'
import {
useS3BackupConfig,
type S3BackupConfig,
type S3BackupScheduleUnit,
} from './composables/useS3BackupConfig'
import type { S3BackupScope } from '@/api/admin'
const backup = useS3BackupConfig()
const backupRunDisabled = computed(() =>
backup.running.value ||
backup.saving.value ||
backup.loading.value ||
backup.hasChanges.value
)
const weekdays = [
{ value: 1, label: '周一' },
{ value: 2, label: '周二' },
{ value: 3, label: '周三' },
{ value: 4, label: '周四' },
{ value: 5, label: '周五' },
{ value: 6, label: '周六' },
{ value: 7, label: '周日' },
]
function setScope(value: string) {
if (value === 'config' || value === 'users' || value === 'data') {
backup.config.value.scope = value as S3BackupScope
}
}
function setScheduleUnit(value: string) {
if (value === 'hours' || value === 'days' || value === 'weeks' || value === 'months') {
backup.config.value.scheduleUnit = value as S3BackupScheduleUnit
}
}
function setNumber(field: keyof Pick<
S3BackupConfig,
| 'retentionCount'
| 'scheduleInterval'
| 'scheduleMinute'
| 'scheduleHour'
| 'scheduleWeekday'
| 'scheduleMonthDay'
>, value: string | number, fallback: number) {
const next = Number(value)
backup.config.value[field] = Number.isFinite(next) ? next : fallback
}
onMounted(() => {
backup.loadS3BackupConfig()
})
</script>
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getSystemConfigMock, updateSystemConfigMock, runS3BackupMock, errorMock, successMock } =
vi.hoisted(() => ({
getSystemConfigMock: vi.fn(),
updateSystemConfigMock: vi.fn(),
runS3BackupMock: vi.fn(),
errorMock: vi.fn(),
successMock: vi.fn(),
}))
vi.mock('@/api/admin', () => ({
adminApi: {
getSystemConfig: getSystemConfigMock,
updateSystemConfig: updateSystemConfigMock,
runS3Backup: runS3BackupMock,
},
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
error: errorMock,
success: successMock,
}),
}))
import { useS3BackupConfig } from '../composables/useS3BackupConfig'
describe('useS3BackupConfig', () => {
beforeEach(() => {
getSystemConfigMock.mockReset()
updateSystemConfigMock.mockReset()
runS3BackupMock.mockReset()
errorMock.mockReset()
successMock.mockReset()
})
it('loads write-only secret as configured without exposing the value', async () => {
getSystemConfigMock.mockImplementation(async (key: string) => {
if (key === 'backup_s3_secret_access_key') {
return { key, value: null, is_set: true }
}
if (key === 'backup_s3_scope') {
return { key, value: 'data' }
}
return { key, value: null }
})
const backup = useS3BackupConfig()
await backup.loadS3BackupConfig()
expect(backup.config.value.scope).toBe('data')
expect(backup.config.value.secretAccessKey).toBe('')
expect(backup.config.value.secretAccessKeyIsSet).toBe(true)
})
it('keeps an existing secret when saving with an empty secret field', async () => {
getSystemConfigMock.mockImplementation(async (key: string) => {
if (key === 'backup_s3_secret_access_key') {
return { key, value: null, is_set: true }
}
return { key, value: null }
})
updateSystemConfigMock.mockResolvedValue({})
const backup = useS3BackupConfig()
await backup.loadS3BackupConfig()
backup.config.value.bucket = 'aether-backups'
await backup.saveS3BackupConfig()
const savedKeys = updateSystemConfigMock.mock.calls.map(([key]) => key)
expect(savedKeys).toContain('backup_s3_bucket')
expect(savedKeys).not.toContain('backup_s3_secret_access_key')
expect(backup.config.value.secretAccessKeyIsSet).toBe(true)
})
it('reloads server state when saving fails before writing a new secret', async () => {
let loadRound = 0
getSystemConfigMock.mockImplementation(async (key: string) => {
if (key === 'backup_s3_secret_access_key') {
return { key, value: null, is_set: false }
}
if (key === 'backup_s3_bucket') {
return { key, value: loadRound === 0 ? 'old-bucket' : 'server-bucket' }
}
return { key, value: null }
})
updateSystemConfigMock.mockImplementation(async (key: string) => {
if (key === 'backup_s3_bucket') {
loadRound += 1
throw new Error('save failed')
}
return {}
})
const backup = useS3BackupConfig()
await backup.loadS3BackupConfig()
backup.config.value.bucket = 'new-bucket'
backup.config.value.secretAccessKey = 'new-secret'
await backup.saveS3BackupConfig()
const savedKeys = updateSystemConfigMock.mock.calls.map(([key]) => key)
expect(savedKeys).toContain('backup_s3_bucket')
expect(savedKeys).not.toContain('backup_s3_secret_access_key')
expect(backup.config.value.bucket).toBe('server-bucket')
expect(backup.config.value.secretAccessKey).toBe('')
})
})
@@ -0,0 +1,283 @@
import { computed, ref } from 'vue'
import { adminApi, type S3BackupScope } from '@/api/admin'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
export type S3BackupScheduleUnit = 'hours' | 'days' | 'weeks' | 'months'
export interface S3BackupConfig {
enabled: boolean
scope: S3BackupScope
endpoint: string
region: string
bucket: string
prefix: string
accessKeyId: string
secretAccessKey: string
secretAccessKeyIsSet: boolean
pathStyle: boolean
compression: string
scheduleUnit: S3BackupScheduleUnit
scheduleInterval: number
scheduleMinute: number
scheduleHour: number
scheduleWeekday: number
scheduleMonthDay: number
retentionCount: number
}
type ConfigField = Exclude<keyof S3BackupConfig, 'secretAccessKeyIsSet'>
const CONFIG_KEY_BY_FIELD: Record<ConfigField, string> = {
enabled: 'backup_s3_enabled',
scope: 'backup_s3_scope',
endpoint: 'backup_s3_endpoint',
region: 'backup_s3_region',
bucket: 'backup_s3_bucket',
prefix: 'backup_s3_prefix',
accessKeyId: 'backup_s3_access_key_id',
secretAccessKey: 'backup_s3_secret_access_key',
pathStyle: 'backup_s3_path_style',
compression: 'backup_s3_compression',
scheduleUnit: 'backup_s3_schedule_unit',
scheduleInterval: 'backup_s3_schedule_interval',
scheduleMinute: 'backup_s3_schedule_minute',
scheduleHour: 'backup_s3_schedule_hour',
scheduleWeekday: 'backup_s3_schedule_weekday',
scheduleMonthDay: 'backup_s3_schedule_month_day',
retentionCount: 'backup_s3_retention_count',
}
const FIELD_BY_CONFIG_KEY = Object.fromEntries(
Object.entries(CONFIG_KEY_BY_FIELD).map(([field, key]) => [key, field])
) as Record<string, ConfigField>
const CONFIG_KEYS = Object.values(CONFIG_KEY_BY_FIELD)
const VALID_SCOPES: S3BackupScope[] = ['config', 'users', 'data']
const VALID_UNITS: S3BackupScheduleUnit[] = ['hours', 'days', 'weeks', 'months']
function defaultS3BackupConfig(): S3BackupConfig {
return {
enabled: false,
scope: 'data',
endpoint: '',
region: 'auto',
bucket: '',
prefix: 'aether/backups/',
accessKeyId: '',
secretAccessKey: '',
secretAccessKeyIsSet: false,
pathStyle: true,
compression: 'zstd',
scheduleUnit: 'days',
scheduleInterval: 1,
scheduleMinute: 0,
scheduleHour: 3,
scheduleWeekday: 1,
scheduleMonthDay: 1,
retentionCount: 7,
}
}
function stringValue(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback
}
function numberValue(value: unknown, fallback: number): number {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return fallback
}
function booleanValue(value: unknown, fallback: boolean): boolean {
if (typeof value === 'boolean') return value
if (value === 'true') return true
if (value === 'false') return false
return fallback
}
function scopeValue(value: unknown, fallback: S3BackupScope): S3BackupScope {
return typeof value === 'string' && VALID_SCOPES.includes(value as S3BackupScope)
? value as S3BackupScope
: fallback
}
function scheduleUnitValue(
value: unknown,
fallback: S3BackupScheduleUnit,
): S3BackupScheduleUnit {
return typeof value === 'string' && VALID_UNITS.includes(value as S3BackupScheduleUnit)
? value as S3BackupScheduleUnit
: fallback
}
function cloneConfig(value: S3BackupConfig): S3BackupConfig {
return JSON.parse(JSON.stringify(value)) as S3BackupConfig
}
export function useS3BackupConfig() {
const { success, error } = useToast()
const config = ref<S3BackupConfig>(defaultS3BackupConfig())
const originalConfig = ref<S3BackupConfig | null>(null)
const loading = ref(false)
const saving = ref(false)
const running = ref(false)
const advancedOpen = ref(false)
const hasChanges = computed(() => {
if (!originalConfig.value) return false
return JSON.stringify({
...config.value,
secretAccessKey: config.value.secretAccessKey.trim(),
}) !== JSON.stringify({
...originalConfig.value,
secretAccessKey: '',
})
})
async function loadS3BackupConfig() {
loading.value = true
try {
const next = defaultS3BackupConfig()
await Promise.all(CONFIG_KEYS.map(async (key) => {
try {
const response = await adminApi.getSystemConfig(key)
const field = FIELD_BY_CONFIG_KEY[key]
if (field === 'secretAccessKey') {
next.secretAccessKey = ''
next.secretAccessKeyIsSet = !!response.is_set
return
}
if (response.value === null || response.value === undefined) return
if (field === 'enabled' || field === 'pathStyle') {
next[field] = booleanValue(response.value, next[field])
} else if (
field === 'scheduleInterval' ||
field === 'scheduleMinute' ||
field === 'scheduleHour' ||
field === 'scheduleWeekday' ||
field === 'scheduleMonthDay' ||
field === 'retentionCount'
) {
next[field] = numberValue(response.value, next[field])
} else if (field === 'scope') {
next.scope = scopeValue(response.value, next.scope)
} else if (field === 'scheduleUnit') {
next.scheduleUnit = scheduleUnitValue(response.value, next.scheduleUnit)
} else {
next[field] = stringValue(response.value, next[field] as string)
}
} catch {
// 单个配置缺失时使用默认值
}
}))
config.value = next
originalConfig.value = cloneConfig(next)
} catch (err) {
error('加载 S3 备份配置失败')
log.error('加载 S3 备份配置失败:', err)
} finally {
loading.value = false
}
}
async function saveS3BackupConfig() {
saving.value = true
try {
const entries: Array<{ key: string; value: unknown; description: string }> = [
{ key: 'backup_s3_enabled', value: config.value.enabled, description: 'S3 自动备份开关' },
{ key: 'backup_s3_scope', value: config.value.scope, description: 'S3 备份范围' },
{ key: 'backup_s3_endpoint', value: config.value.endpoint.trim() || null, description: 'S3 Endpoint' },
{ key: 'backup_s3_region', value: config.value.region.trim() || 'auto', description: 'S3 Region' },
{ key: 'backup_s3_bucket', value: config.value.bucket.trim() || null, description: 'S3 Bucket' },
{ key: 'backup_s3_prefix', value: config.value.prefix.trim() || 'aether/backups/', description: 'S3 备份前缀' },
{ key: 'backup_s3_access_key_id', value: config.value.accessKeyId.trim() || null, description: 'S3 Access Key ID' },
{ key: 'backup_s3_path_style', value: config.value.pathStyle, description: 'S3 Path Style' },
{ key: 'backup_s3_compression', value: config.value.compression || 'zstd', description: 'S3 备份压缩格式' },
{ key: 'backup_s3_schedule_unit', value: config.value.scheduleUnit, description: 'S3 备份周期单位' },
{ key: 'backup_s3_schedule_interval', value: Math.max(1, Math.floor(config.value.scheduleInterval || 1)), description: 'S3 备份周期间隔' },
{ key: 'backup_s3_schedule_minute', value: Math.min(59, Math.max(0, Math.floor(config.value.scheduleMinute || 0))), description: 'S3 备份分钟' },
{ key: 'backup_s3_schedule_hour', value: Math.min(23, Math.max(0, Math.floor(config.value.scheduleHour || 0))), description: 'S3 备份小时' },
{ key: 'backup_s3_schedule_weekday', value: Math.min(7, Math.max(1, Math.floor(config.value.scheduleWeekday || 1))), description: 'S3 备份星期' },
{ key: 'backup_s3_schedule_month_day', value: Math.min(31, Math.max(1, Math.floor(config.value.scheduleMonthDay || 1))), description: 'S3 备份月日' },
{ key: 'backup_s3_retention_count', value: Math.max(1, Math.floor(config.value.retentionCount || 1)), description: 'S3 备份保留份数' },
]
const secret = config.value.secretAccessKey.trim()
if (secret) {
entries.push({
key: 'backup_s3_secret_access_key',
value: secret,
description: 'S3 Secret Access Key',
})
}
for (const item of entries) {
await adminApi.updateSystemConfig(item.key, item.value, item.description)
}
if (secret) {
config.value.secretAccessKey = ''
config.value.secretAccessKeyIsSet = true
}
originalConfig.value = cloneConfig(config.value)
success('S3 备份配置已保存')
} catch (err) {
error(parseApiError(err, '保存 S3 备份配置失败'))
log.error('保存 S3 备份配置失败:', err)
await loadS3BackupConfig()
} finally {
saving.value = false
}
}
async function clearS3SecretAccessKey() {
saving.value = true
try {
await adminApi.updateSystemConfig(
'backup_s3_secret_access_key',
'',
'S3 Secret Access Key',
)
config.value.secretAccessKey = ''
config.value.secretAccessKeyIsSet = false
if (originalConfig.value) {
originalConfig.value.secretAccessKey = ''
originalConfig.value.secretAccessKeyIsSet = false
}
success('S3 访问密钥已清除')
} catch (err) {
error(parseApiError(err, '清除 S3 访问密钥失败'))
log.error('清除 S3 访问密钥失败:', err)
} finally {
saving.value = false
}
}
async function runS3BackupNow() {
running.value = true
try {
const response = await adminApi.runS3Backup()
success(response.message || 'S3 备份任务已提交')
} catch (err) {
error(parseApiError(err, '提交 S3 备份任务失败'))
log.error('提交 S3 备份任务失败:', err)
} finally {
running.value = false
}
}
return {
config,
loading,
saving,
running,
advancedOpen,
hasChanges,
loadS3BackupConfig,
saveS3BackupConfig,
clearS3SecretAccessKey,
runS3BackupNow,
}
}