feat: add backup and restore
This commit is contained in:
@@ -13,6 +13,9 @@ data/
|
|||||||
# logs/
|
# logs/
|
||||||
# scripts/
|
# scripts/
|
||||||
configs/config.ini
|
configs/config.ini
|
||||||
|
web/dist/*
|
||||||
|
!web/dist/.gitkeep
|
||||||
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"baihu/internal/constant"
|
"baihu/internal/constant"
|
||||||
@@ -20,6 +21,7 @@ type SettingsController struct {
|
|||||||
userService *services.UserService
|
userService *services.UserService
|
||||||
settingsService *services.SettingsService
|
settingsService *services.SettingsService
|
||||||
loginLogService *services.LoginLogService
|
loginLogService *services.LoginLogService
|
||||||
|
backupService *services.BackupService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSettingsController(userService *services.UserService, loginLogService *services.LoginLogService) *SettingsController {
|
func NewSettingsController(userService *services.UserService, loginLogService *services.LoginLogService) *SettingsController {
|
||||||
@@ -27,6 +29,7 @@ func NewSettingsController(userService *services.UserService, loginLogService *s
|
|||||||
userService: userService,
|
userService: userService,
|
||||||
settingsService: services.NewSettingsService(),
|
settingsService: services.NewSettingsService(),
|
||||||
loginLogService: loginLogService,
|
loginLogService: loginLogService,
|
||||||
|
backupService: services.NewBackupService(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +177,6 @@ func formatDuration(d time.Duration) string {
|
|||||||
return fmt.Sprintf("%d秒", seconds)
|
return fmt.Sprintf("%d秒", seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// GetLoginLogs 获取登录日志
|
// GetLoginLogs 获取登录日志
|
||||||
func (sc *SettingsController) GetLoginLogs(c *gin.Context) {
|
func (sc *SettingsController) GetLoginLogs(c *gin.Context) {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
@@ -201,3 +203,80 @@ func (sc *SettingsController) GetLoginLogs(c *gin.Context) {
|
|||||||
"page_size": pageSize,
|
"page_size": pageSize,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateBackup 创建备份
|
||||||
|
func (sc *SettingsController) CreateBackup(c *gin.Context) {
|
||||||
|
_, err := sc.backupService.CreateBackup()
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerError(c, "创建备份失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.SuccessMsg(c, "备份创建成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBackupStatus 获取备份状态
|
||||||
|
func (sc *SettingsController) GetBackupStatus(c *gin.Context) {
|
||||||
|
filePath := sc.backupService.GetBackupFile()
|
||||||
|
var backupTime string
|
||||||
|
if filePath != "" {
|
||||||
|
if info, err := os.Stat(filePath); err == nil {
|
||||||
|
backupTime = info.ModTime().Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
utils.Success(c, gin.H{
|
||||||
|
"has_backup": filePath != "",
|
||||||
|
"backup_time": backupTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadBackup 下载备份文件
|
||||||
|
func (sc *SettingsController) DownloadBackup(c *gin.Context) {
|
||||||
|
filePath := sc.backupService.GetBackupFile()
|
||||||
|
if filePath == "" {
|
||||||
|
utils.NotFound(c, "没有可下载的备份")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
sc.backupService.ClearBackup()
|
||||||
|
utils.NotFound(c, "备份文件不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
c.Header("Content-Disposition", "attachment; filename="+filepath.Base(filePath))
|
||||||
|
c.Header("Content-Type", "application/zip")
|
||||||
|
c.File(filePath)
|
||||||
|
|
||||||
|
// 下载后清除备份记录和文件
|
||||||
|
go func() {
|
||||||
|
time.Sleep(time.Minute * 5) // 等待下载完成
|
||||||
|
sc.backupService.ClearBackup()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreBackup 恢复备份
|
||||||
|
func (sc *SettingsController) RestoreBackup(c *gin.Context) {
|
||||||
|
file, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
utils.BadRequest(c, "请上传备份文件")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存上传的文件
|
||||||
|
tempPath := filepath.Join(os.TempDir(), file.Filename)
|
||||||
|
if err := c.SaveUploadedFile(file, tempPath); err != nil {
|
||||||
|
utils.ServerError(c, "保存文件失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(tempPath)
|
||||||
|
|
||||||
|
// 恢复备份
|
||||||
|
if err := sc.backupService.Restore(tempPath); err != nil {
|
||||||
|
utils.ServerError(c, "恢复失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessMsg(c, "恢复成功")
|
||||||
|
}
|
||||||
|
|||||||
@@ -177,6 +177,10 @@ func Setup(c *Controllers) *gin.Engine {
|
|||||||
settings.PUT("/site", c.Settings.UpdateSiteSettings)
|
settings.PUT("/site", c.Settings.UpdateSiteSettings)
|
||||||
settings.GET("/about", c.Settings.GetAbout)
|
settings.GET("/about", c.Settings.GetAbout)
|
||||||
settings.GET("/loginlogs", c.Settings.GetLoginLogs)
|
settings.GET("/loginlogs", c.Settings.GetLoginLogs)
|
||||||
|
settings.POST("/backup", c.Settings.CreateBackup)
|
||||||
|
settings.GET("/backup/status", c.Settings.GetBackupStatus)
|
||||||
|
settings.GET("/backup/download", c.Settings.DownloadBackup)
|
||||||
|
settings.POST("/restore", c.Settings.RestoreBackup)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runtime routes (依赖管理)
|
// Runtime routes (依赖管理)
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"baihu/internal/constant"
|
||||||
|
"baihu/internal/database"
|
||||||
|
"baihu/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BackupService struct {
|
||||||
|
settingsService *SettingsService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBackupService() *BackupService {
|
||||||
|
return &BackupService{
|
||||||
|
settingsService: NewSettingsService(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
BackupSection = "backup"
|
||||||
|
BackupFileKey = "backup_file"
|
||||||
|
BackupDir = "./data/backups"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tableConfig 表备份配置
|
||||||
|
type tableConfig struct {
|
||||||
|
filename string
|
||||||
|
export func() (any, error)
|
||||||
|
restore func([]byte) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackupService) getTableConfigs() []tableConfig {
|
||||||
|
return []tableConfig{
|
||||||
|
{"tasks.json", s.exportTable(&[]models.Task{}, true), s.restoreTable(&[]models.Task{}, true)},
|
||||||
|
{"task_logs.json", s.exportTable(&[]models.TaskLog{}, false), s.restoreTable(&[]models.TaskLog{}, false)},
|
||||||
|
{"envs.json", s.exportTable(&[]models.EnvironmentVariable{}, true), s.restoreTable(&[]models.EnvironmentVariable{}, true)},
|
||||||
|
{"scripts.json", s.exportTable(&[]models.Script{}, true), s.restoreTable(&[]models.Script{}, true)},
|
||||||
|
{"settings.json", s.exportSettings, s.restoreSettings},
|
||||||
|
{"send_stats.json", s.exportTable(&[]models.SendStats{}, false), s.restoreTable(&[]models.SendStats{}, false)},
|
||||||
|
{"login_logs.json", s.exportTable(&[]models.LoginLog{}, false), s.restoreTable(&[]models.LoginLog{}, false)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackupService) exportTable(dest any, unscoped bool) func() (any, error) {
|
||||||
|
return func() (any, error) {
|
||||||
|
db := database.DB
|
||||||
|
if unscoped {
|
||||||
|
db = db.Unscoped()
|
||||||
|
}
|
||||||
|
db.Find(dest)
|
||||||
|
return dest, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackupService) restoreTable(dest any, unscoped bool) func([]byte) error {
|
||||||
|
return func(data []byte) error {
|
||||||
|
if err := json.Unmarshal(data, dest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackupService) exportSettings() (any, error) {
|
||||||
|
var data []models.Setting
|
||||||
|
database.DB.Where("section != ?", BackupSection).Find(&data)
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackupService) restoreSettings(data []byte) error {
|
||||||
|
var settings []models.Setting
|
||||||
|
return json.Unmarshal(data, &settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBackup 创建备份
|
||||||
|
func (s *BackupService) CreateBackup() (string, error) {
|
||||||
|
if err := os.MkdirAll(BackupDir, 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
|
zipPath := filepath.Join(BackupDir, fmt.Sprintf("backup_%s.zip", timestamp))
|
||||||
|
|
||||||
|
zipFile, err := os.Create(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer zipFile.Close()
|
||||||
|
|
||||||
|
zipWriter := zip.NewWriter(zipFile)
|
||||||
|
defer zipWriter.Close()
|
||||||
|
|
||||||
|
// 导出各表
|
||||||
|
for _, cfg := range s.getTableConfigs() {
|
||||||
|
data, err := cfg.export()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
w, err := zipWriter.Create(cfg.filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(jsonData); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打包 scripts 文件夹
|
||||||
|
scriptsDir := constant.ScriptsWorkDir
|
||||||
|
if _, err := os.Stat(scriptsDir); err == nil {
|
||||||
|
if err := s.addDirToZip(zipWriter, scriptsDir, "scripts"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.settingsService.Set(BackupSection, BackupFileKey, zipPath)
|
||||||
|
return zipPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore 恢复备份
|
||||||
|
func (s *BackupService) Restore(zipPath string) error {
|
||||||
|
r, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
// 构建文件名到配置的映射
|
||||||
|
configs := s.getTableConfigs()
|
||||||
|
fileMap := make(map[string]*zip.File)
|
||||||
|
for _, f := range r.File {
|
||||||
|
fileMap[f.Name] = f
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取所有表数据
|
||||||
|
tableData := make(map[string][]byte)
|
||||||
|
for _, cfg := range configs {
|
||||||
|
if f, ok := fileMap[cfg.filename]; ok {
|
||||||
|
data, err := s.readZipFile(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tableData[cfg.filename] = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空现有数据(物理删除)
|
||||||
|
database.DB.Unscoped().Where("1=1").Delete(&models.Task{})
|
||||||
|
database.DB.Unscoped().Where("1=1").Delete(&models.TaskLog{})
|
||||||
|
database.DB.Unscoped().Where("1=1").Delete(&models.EnvironmentVariable{})
|
||||||
|
database.DB.Unscoped().Where("1=1").Delete(&models.Script{})
|
||||||
|
database.DB.Unscoped().Where("section != ?", BackupSection).Delete(&models.Setting{})
|
||||||
|
database.DB.Unscoped().Where("1=1").Delete(&models.SendStats{})
|
||||||
|
database.DB.Unscoped().Where("1=1").Delete(&models.LoginLog{})
|
||||||
|
|
||||||
|
// 恢复数据
|
||||||
|
s.restoreFromData(tableData, "tasks.json", &[]models.Task{})
|
||||||
|
s.restoreFromData(tableData, "task_logs.json", &[]models.TaskLog{})
|
||||||
|
s.restoreFromData(tableData, "envs.json", &[]models.EnvironmentVariable{})
|
||||||
|
s.restoreFromData(tableData, "scripts.json", &[]models.Script{})
|
||||||
|
s.restoreFromData(tableData, "settings.json", &[]models.Setting{})
|
||||||
|
s.restoreFromData(tableData, "send_stats.json", &[]models.SendStats{})
|
||||||
|
s.restoreFromData(tableData, "login_logs.json", &[]models.LoginLog{})
|
||||||
|
|
||||||
|
// 恢复 scripts 文件夹
|
||||||
|
s.restoreScriptsDir(r)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackupService) restoreFromData(tableData map[string][]byte, filename string, dest any) {
|
||||||
|
if data, ok := tableData[filename]; ok {
|
||||||
|
if err := json.Unmarshal(data, dest); err == nil {
|
||||||
|
s.insertRecords(dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackupService) insertRecords(records any) {
|
||||||
|
switch v := records.(type) {
|
||||||
|
case *[]models.Task:
|
||||||
|
for _, r := range *v {
|
||||||
|
database.DB.Create(&r)
|
||||||
|
}
|
||||||
|
case *[]models.TaskLog:
|
||||||
|
for _, r := range *v {
|
||||||
|
database.DB.Create(&r)
|
||||||
|
}
|
||||||
|
case *[]models.EnvironmentVariable:
|
||||||
|
for _, r := range *v {
|
||||||
|
database.DB.Create(&r)
|
||||||
|
}
|
||||||
|
case *[]models.Script:
|
||||||
|
for _, r := range *v {
|
||||||
|
database.DB.Create(&r)
|
||||||
|
}
|
||||||
|
case *[]models.Setting:
|
||||||
|
for _, r := range *v {
|
||||||
|
database.DB.Create(&r)
|
||||||
|
}
|
||||||
|
case *[]models.SendStats:
|
||||||
|
for _, r := range *v {
|
||||||
|
database.DB.Create(&r)
|
||||||
|
}
|
||||||
|
case *[]models.LoginLog:
|
||||||
|
for _, r := range *v {
|
||||||
|
database.DB.Create(&r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackupService) restoreScriptsDir(r *zip.ReadCloser) {
|
||||||
|
scriptsDir := constant.ScriptsWorkDir
|
||||||
|
for _, f := range r.File {
|
||||||
|
if len(f.Name) > 8 && f.Name[:8] == "scripts/" {
|
||||||
|
relPath := f.Name[8:]
|
||||||
|
if relPath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fpath := filepath.Join(scriptsDir, relPath)
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
os.MkdirAll(fpath, 0755)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
os.MkdirAll(filepath.Dir(fpath), 0755)
|
||||||
|
if outFile, err := os.Create(fpath); err == nil {
|
||||||
|
if rc, err := f.Open(); err == nil {
|
||||||
|
io.Copy(outFile, rc)
|
||||||
|
rc.Close()
|
||||||
|
}
|
||||||
|
outFile.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackupService) readZipFile(f *zip.File) ([]byte, error) {
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
return io.ReadAll(rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackupService) addDirToZip(zipWriter *zip.Writer, srcDir, prefix string) error {
|
||||||
|
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
relPath, err := filepath.Rel(srcDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
zipPath := filepath.ToSlash(filepath.Join(prefix, relPath))
|
||||||
|
if info.IsDir() {
|
||||||
|
if relPath != "." {
|
||||||
|
_, err := zipWriter.Create(zipPath + "/")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w, err := zipWriter.Create(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
_, err = io.Copy(w, file)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackupService) GetBackupFile() string {
|
||||||
|
var setting models.Setting
|
||||||
|
if err := database.DB.Where("section = ? AND `key` = ?", BackupSection, BackupFileKey).First(&setting).Error; err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return setting.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackupService) ClearBackup() error {
|
||||||
|
filePath := s.GetBackupFile()
|
||||||
|
if filePath != "" {
|
||||||
|
os.Remove(filePath)
|
||||||
|
database.DB.Where("section = ? AND `key` = ?", BackupSection, BackupFileKey).Delete(&models.Setting{})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -121,6 +121,24 @@ export const api = {
|
|||||||
if (params?.page_size) query.set('page_size', String(params.page_size))
|
if (params?.page_size) query.set('page_size', String(params.page_size))
|
||||||
if (params?.username) query.set('username', params.username)
|
if (params?.username) query.set('username', params.username)
|
||||||
return request<LoginLogListResponse>(`/settings/loginlogs?${query}`)
|
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: {
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import PasswordSettings from './PasswordSettings.vue'
|
import PasswordSettings from './PasswordSettings.vue'
|
||||||
import SiteSettings from './SiteSettings.vue'
|
import SiteSettings from './SiteSettings.vue'
|
||||||
|
import BackupSettings from './BackupSettings.vue'
|
||||||
import AboutSettings from './AboutSettings.vue'
|
import AboutSettings from './AboutSettings.vue'
|
||||||
|
|
||||||
const activeTab = ref('password')
|
const activeTab = ref('password')
|
||||||
@@ -20,6 +21,7 @@ const activeTab = ref('password')
|
|||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="password">密码修改</TabsTrigger>
|
<TabsTrigger value="password">密码修改</TabsTrigger>
|
||||||
<TabsTrigger value="site">站点设置</TabsTrigger>
|
<TabsTrigger value="site">站点设置</TabsTrigger>
|
||||||
|
<TabsTrigger value="backup">备份恢复</TabsTrigger>
|
||||||
<TabsTrigger value="about">关于</TabsTrigger>
|
<TabsTrigger value="about">关于</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -47,6 +49,18 @@ const activeTab = ref('password')
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="about" class="mt-6">
|
||||||
<Card class="max-w-xl">
|
<Card class="max-w-xl">
|
||||||
<CardContent class="pt-6">
|
<CardContent class="pt-6">
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ async function loadSettings() {
|
|||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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()
|
await refreshSettings()
|
||||||
toast.success('保存成功')
|
toast.success('保存成功')
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user