feat: add backup and restore
This commit is contained in:
@@ -121,6 +121,24 @@ export const api = {
|
||||
if (params?.page_size) query.set('page_size', String(params.page_size))
|
||||
if (params?.username) query.set('username', params.username)
|
||||
return request<LoginLogListResponse>(`/settings/loginlogs?${query}`)
|
||||
},
|
||||
createBackup: () => request('/settings/backup', { method: 'POST' }),
|
||||
getBackupStatus: () => request<{ has_backup: boolean; backup_time: string }>('/settings/backup/status'),
|
||||
downloadBackup: () => `${BASE_URL}/settings/backup/download`,
|
||||
restoreBackup: async (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await fetch(`${BASE_URL}/settings/restore`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
})
|
||||
const json: ApiResponse<null> = await res.json()
|
||||
if (json.code === 401) {
|
||||
window.location.href = '/login'
|
||||
throw new Error('请先登录')
|
||||
}
|
||||
if (json.code !== 200) throw new Error(json.msg || '恢复失败')
|
||||
}
|
||||
},
|
||||
files: {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { api } from '@/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Download, Upload, Archive } from 'lucide-vue-next'
|
||||
|
||||
const hasBackup = ref(false)
|
||||
const backupTime = ref('')
|
||||
const backupLoading = ref(false)
|
||||
const restoreLoading = ref(false)
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const showConfirm = ref(false)
|
||||
const pendingFile = ref<File | null>(null)
|
||||
|
||||
async function checkBackupStatus() {
|
||||
try {
|
||||
const res = await api.settings.getBackupStatus()
|
||||
hasBackup.value = res.has_backup
|
||||
backupTime.value = res.backup_time || ''
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
backupLoading.value = true
|
||||
try {
|
||||
await api.settings.createBackup()
|
||||
toast.success('备份创建成功')
|
||||
await checkBackupStatus()
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '备份失败')
|
||||
} finally {
|
||||
backupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBackup() {
|
||||
window.open(api.settings.downloadBackup(), '_blank')
|
||||
setTimeout(checkBackupStatus, 6000)
|
||||
}
|
||||
|
||||
function triggerFileSelect() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.name.endsWith('.zip')) {
|
||||
toast.error('请选择 .zip 备份文件')
|
||||
target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
pendingFile.value = file
|
||||
showConfirm.value = true
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
async function confirmRestore() {
|
||||
if (!pendingFile.value) return
|
||||
|
||||
showConfirm.value = false
|
||||
restoreLoading.value = true
|
||||
try {
|
||||
await api.settings.restoreBackup(pendingFile.value)
|
||||
toast.success('恢复成功,页面即将刷新')
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '恢复失败')
|
||||
} finally {
|
||||
restoreLoading.value = false
|
||||
pendingFile.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRestore() {
|
||||
showConfirm.value = false
|
||||
pendingFile.value = null
|
||||
}
|
||||
|
||||
onMounted(checkBackupStatus)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<Button @click="createBackup" :disabled="backupLoading" variant="outline">
|
||||
<Archive class="w-4 h-4 mr-2" />
|
||||
{{ backupLoading ? '备份中...' : '创建备份' }}
|
||||
</Button>
|
||||
<Button v-if="hasBackup" @click="downloadBackup" variant="outline">
|
||||
<Download class="w-4 h-4 mr-2" />
|
||||
下载备份
|
||||
</Button>
|
||||
<span v-if="hasBackup && backupTime" class="text-xs text-muted-foreground">{{ backupTime }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
备份包含:任务、执行日志、环境变量、脚本、设置及 scripts 文件夹。第一次下载后,5分钟后文件将被删除。
|
||||
</div>
|
||||
<div class="border-t pt-4 mt-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<Button @click="triggerFileSelect" :disabled="restoreLoading" variant="outline">
|
||||
<Upload class="w-4 h-4 mr-2" />
|
||||
{{ restoreLoading ? '恢复中...' : '恢复备份' }}
|
||||
</Button>
|
||||
<input ref="fileInput" type="file" accept=".zip" class="hidden" @change="handleFileSelect" />
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-2">
|
||||
上传 .zip 备份文件进行恢复,恢复会覆盖现有数据
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog :open="showConfirm" @update:open="showConfirm = $event">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认恢复</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
恢复备份将覆盖现有所有数据,此操作不可撤销。确定要继续吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel @click="cancelRestore">取消</AlertDialogCancel>
|
||||
<AlertDialogAction @click="confirmRestore">确认恢复</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import PasswordSettings from './PasswordSettings.vue'
|
||||
import SiteSettings from './SiteSettings.vue'
|
||||
import BackupSettings from './BackupSettings.vue'
|
||||
import AboutSettings from './AboutSettings.vue'
|
||||
|
||||
const activeTab = ref('password')
|
||||
@@ -20,6 +21,7 @@ const activeTab = ref('password')
|
||||
<TabsList>
|
||||
<TabsTrigger value="password">密码修改</TabsTrigger>
|
||||
<TabsTrigger value="site">站点设置</TabsTrigger>
|
||||
<TabsTrigger value="backup">备份恢复</TabsTrigger>
|
||||
<TabsTrigger value="about">关于</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -47,6 +49,18 @@ const activeTab = ref('password')
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backup" class="mt-6">
|
||||
<Card class="max-w-xl">
|
||||
<CardHeader>
|
||||
<CardTitle>备份恢复</CardTitle>
|
||||
<CardDescription>备份和恢复系统数据</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BackupSettings />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="about" class="mt-6">
|
||||
<Card class="max-w-xl">
|
||||
<CardContent class="pt-6">
|
||||
|
||||
@@ -37,7 +37,11 @@ async function loadSettings() {
|
||||
async function saveSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
await api.settings.updateSite(form.value)
|
||||
await api.settings.updateSite({
|
||||
...form.value,
|
||||
page_size: String(form.value.page_size),
|
||||
cookie_days: String(form.value.cookie_days)
|
||||
})
|
||||
await refreshSettings()
|
||||
toast.success('保存成功')
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user