Compare commits

...

10 Commits

Author SHA1 Message Date
Awuqing
8eb93b3dd9 功能: Docker 一键自动更新
- 新增 POST /api/system/update-apply,执行 docker pull + docker compose up -d
- 前端系统设置页新增「一键更新(Docker)」按钮,点击后自动拉取新镜像并重启容器
- Dockerfile 安装 docker-cli + docker-cli-compose
- docker-compose.yml 挂载 /var/run/docker.sock 以支持容器内操作 Docker
- 自动检测是否为 Docker 环境,非 Docker 环境引导下载二进制
2026-04-01 23:43:12 +08:00
Wu Qing
df5c8aa80d 功能: 系统更新检查 (#31)
功能: 系统更新检查(GitHub Release + Docker)
2026-04-01 23:18:21 +08:00
Awuqing
9a4556f473 功能: 系统更新检查(GitHub Release + Docker)
后端:
- 新增 GET /api/system/update-check,从 GitHub Releases API 获取最新版本
- 自动比较当前版本与最新版本,匹配当前平台的下载链接
- 返回版本号、更新说明、下载链接、Docker 镜像信息

前端(系统设置页重构):
- 新增"检查更新"按钮,点击后展示更新结果
- 有新版本时显示版本号、更新说明、下载按钮、Docker 更新命令
- 新增磁盘状态卡片(总空间/已用/可用/使用率)
- 运行模式用彩色 Tag 区分(生产/开发)
2026-04-01 23:13:32 +08:00
Wu Qing
a772b94ca5 修复: rclone 后端列表不显示 + 调度审计 + 批量删除 (#30)
修复: rclone 后端列表不显示 + 调度审计 + 批量删除
2026-04-01 23:02:40 +08:00
Awuqing
3bd15bf3fd 修复: rclone 后端列表不显示 + 调度审计 + 批量删除
1. 修复前端 rclone 后端 API 路径双重 /api 前缀导致 404,
   存储类型下拉框现在正确显示全部 70+ rclone 后端
2. 调度器自动触发的备份任务计入审计日志(用户名: system)
3. 新增备份记录批量删除 API (POST /api/backup/records/batch-delete)
2026-04-01 22:57:55 +08:00
Wu Qing
5ae7fb2f5d 修复: 上传操作级重试 (#29)
修复: 上传操作级重试,解决远端临时故障导致自动备份失败
2026-04-01 18:40:13 +08:00
Awuqing
37ad6b1db1 修复: 上传操作级重试,解决 Google Drive 等远端临时故障导致自动备份连续失败
问题:rclone 底层重试只覆盖单个 HTTP 请求,但 Google API 的 502/timeout
等临时故障会导致整个上传操作失败,自动触发的备份任务连续失败。

修复:在 provider.Upload 外层增加操作级重试(最多 3 次,指数退避 10s/40s/90s),
每次重试重新打开文件并重建 reader 链。重试过程通过日志流实时反馈。
2026-04-01 18:35:26 +08:00
Wu Qing
d9e0609089 功能: 全部 rclone 后端注册为一级存储类型 (#28)
功能: 全部 rclone 后端注册为一级存储类型
2026-04-01 12:59:29 +08:00
Awuqing
ab9919f15f 功能: 全部 rclone 后端注册为一级存储类型
将全部 70+ rclone 后端(SFTP、Azure Blob、Dropbox、OneDrive、B2、SMB 等)
自动注册为独立 Factory,与 S3、FTP 等内置类型完全平级。

- 新增 GenericBackendFactory + RegisterAllBackends 自动注册全部后端
- 移除 oneof 硬编码白名单,type 字段接受任意已注册存储类型
- 前端类型选择器合并内置类型和全部 rclone 后端为统一可搜索下拉框
- 选择 SFTP 直接存储 type="sftp",非内置类型自动从 API 获取配置字段
2026-04-01 12:52:06 +08:00
Wu Qing
d70b4094af 优化: 重新设计 Cron 编辑器交互体验 (#27)
优化: 重新设计 Cron 编辑器交互体验
2026-04-01 07:50:16 +08:00
17 changed files with 636 additions and 592 deletions

View File

@@ -55,6 +55,7 @@ RUN apk add --no-cache \
nginx \
tzdata \
ca-certificates \
docker-cli docker-cli-compose \
# Required by mysql/postgresql backup tasks
mysql-client \
postgresql16-client \

View File

@@ -15,6 +15,7 @@ services:
- "8340:8340"
volumes:
- backupx-data:/app/data
- /var/run/docker.sock:/var/run/docker.sock # 支持 Web 一键更新
# 挂载需要备份的宿主机目录(按需添加,:ro 表示只读):
# - /var/www:/mnt/www:ro
# - /etc/nginx:/mnt/nginx-conf:ro

View File

@@ -73,6 +73,8 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
storageRclone.NewFTPFactory(),
storageRclone.NewRcloneFactory(),
)
// 将全部 rclone 后端注册为独立存储类型sftp、azureblob、dropbox 等与 s3、ftp 完全平级)
storageRclone.RegisterAllBackends(storageRegistry)
storageTargetService := service.NewStorageTargetService(storageTargetRepo, oauthSessionRepo, storageRegistry, configCipher)
storageTargetService.SetBackupTaskRepository(backupTaskRepo)
storageTargetService.SetBackupRecordRepository(backupRecordRepo)
@@ -92,6 +94,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
backupExecutionService := service.NewBackupExecutionService(backupTaskRepo, backupRecordRepo, storageTargetRepo, storageRegistry, backupRunnerRegistry, logHub, retentionService, configCipher, notificationService, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent, cfg.Backup.Retries, cfg.Backup.BandwidthLimit)
schedulerService := scheduler.NewService(backupTaskRepo, backupExecutionService, appLogger)
backupTaskService.SetScheduler(schedulerService)
// 审计日志注入延迟到 auditService 创建后(见下方)
backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub)
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
settingsService := service.NewSettingsService(systemConfigRepo)
@@ -100,6 +103,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
auditLogRepo := repository.NewAuditLogRepository(db)
auditService := service.NewAuditService(auditLogRepo)
authService.SetAuditService(auditService)
schedulerService.SetAuditRecorder(auditService)
// Database discovery
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())

View File

@@ -147,6 +147,24 @@ func (h *BackupRecordHandler) Delete(c *gin.Context) {
response.Success(c, gin.H{"deleted": true})
}
func (h *BackupRecordHandler) BatchDelete(c *gin.Context) {
var input struct {
IDs []uint `json:"ids" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("BACKUP_RECORD_BATCH_INVALID", "批量删除参数不合法", err))
return
}
deleted := 0
for _, id := range input.IDs {
if err := h.service.Delete(c.Request.Context(), id); err == nil {
deleted++
}
}
recordAudit(c, h.auditService, "backup_record", "batch_delete", "backup_record", "", "", fmt.Sprintf("批量删除 %d 条备份记录", deleted))
response.Success(c, gin.H{"deleted": deleted})
}
func buildRecordFilter(c *gin.Context) (service.BackupRecordListInput, error) {
var filter service.BackupRecordListInput
if taskIDValue := strings.TrimSpace(c.Query("taskId")); taskIDValue != "" {

View File

@@ -68,6 +68,8 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
system := api.Group("/system")
system.Use(AuthMiddleware(deps.JWTManager))
system.GET("/info", systemHandler.Info)
system.GET("/update-check", systemHandler.CheckUpdate)
system.POST("/update-apply", systemHandler.ApplyUpdate)
storageTargets := api.Group("/storage-targets")
storageTargets.Use(AuthMiddleware(deps.JWTManager))
@@ -106,6 +108,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
backupRecords.GET("/:id/download", backupRecordHandler.Download)
backupRecords.POST("/:id/restore", backupRecordHandler.Restore)
backupRecords.POST("/batch-delete", backupRecordHandler.BatchDelete)
backupRecords.DELETE("/:id", backupRecordHandler.Delete)
dashboard := api.Group("/dashboard")
dashboard.Use(AuthMiddleware(deps.JWTManager))

View File

@@ -17,3 +17,26 @@ func NewSystemHandler(systemService *service.SystemService) *SystemHandler {
func (h *SystemHandler) Info(c *gin.Context) {
response.Success(c, h.systemService.GetInfo(c.Request.Context()))
}
func (h *SystemHandler) ApplyUpdate(c *gin.Context) {
var input struct {
Version string `json:"version"`
}
_ = c.ShouldBindJSON(&input)
result := h.systemService.ApplyDockerUpdate(c.Request.Context(), input.Version)
response.Success(c, result)
}
func (h *SystemHandler) CheckUpdate(c *gin.Context) {
result, err := h.systemService.CheckUpdate(c.Request.Context())
if err != nil {
// 即使检查失败也返回当前版本信息
response.Success(c, gin.H{
"currentVersion": result.CurrentVersion,
"hasUpdate": false,
"error": err.Error(),
})
return
}
response.Success(c, result)
}

View File

@@ -17,12 +17,18 @@ type TaskRunner interface {
RunTaskByID(context.Context, uint) (*servicepkg.BackupRecordDetail, error)
}
// AuditRecorder 记录审计日志(可选依赖)
type AuditRecorder interface {
Record(servicepkg.AuditEntry)
}
type Service struct {
mu sync.Mutex
cron *cron.Cron
tasks repository.BackupTaskRepository
runner TaskRunner
logger *zap.Logger
audit AuditRecorder
entries map[uint]cron.EntryID
}
@@ -31,6 +37,8 @@ func NewService(tasks repository.BackupTaskRepository, runner TaskRunner, logger
return &Service{cron: cron.New(cron.WithParser(parser), cron.WithLocation(time.UTC)), tasks: tasks, runner: runner, logger: logger, entries: make(map[uint]cron.EntryID)}
}
func (s *Service) SetAuditRecorder(audit AuditRecorder) { s.audit = audit }
func (s *Service) Start(ctx context.Context) error {
if err := s.Reload(ctx); err != nil {
return err
@@ -96,9 +104,19 @@ func (s *Service) syncTaskLocked(task *model.BackupTask) error {
if !task.Enabled || task.CronExpr == "" {
return nil
}
taskID := task.ID
taskName := task.Name
entryID, err := s.cron.AddFunc(task.CronExpr, func() {
if _, runErr := s.runner.RunTaskByID(context.Background(), task.ID); runErr != nil && s.logger != nil {
s.logger.Warn("scheduled backup run failed", zap.Uint("task_id", task.ID), zap.Error(runErr))
// 自动调度任务记录审计日志
if s.audit != nil {
s.audit.Record(servicepkg.AuditEntry{
Username: "system", Category: "backup_task", Action: "scheduled_run",
TargetType: "backup_task", TargetID: fmt.Sprintf("%d", taskID),
TargetName: taskName, Detail: fmt.Sprintf("定时调度触发备份任务: %s (cron: %s)", taskName, task.CronExpr),
})
}
if _, runErr := s.runner.RunTaskByID(context.Background(), taskID); runErr != nil && s.logger != nil {
s.logger.Warn("scheduled backup run failed", zap.Uint("task_id", taskID), zap.Error(runErr))
}
})
if err != nil {

View File

@@ -363,33 +363,46 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
logger.Warnf("存储目标 %s 创建客户端失败:%v", targetName, resolveErr)
return
}
artifact, openErr := os.Open(finalPath)
if openErr != nil {
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: openErr.Error()}
logger.Warnf("存储目标 %s 打开备份文件失败:%v", targetName, openErr)
return
}
defer artifact.Close()
logger.Infof("开始上传备份到存储目标:%s", targetName)
// hashingReader: 上传过程中同步计算字节数 + SHA-256单次读取零额外 I/O
hr := newHashingReader(artifact)
// progressReader: 包装 hashingReader通过 LogHub 推送实时上传进度
pr := newProgressReader(hr, fileSize, func(bytesRead int64, speedBps float64) {
percent := float64(0)
if fileSize > 0 {
percent = float64(bytesRead) / float64(fileSize) * 100
// 上传级重试:最多 3 次指数退避10s, 30s, 90s
maxAttempts := 3
var lastUploadErr error
var hr *hashingReader
for attempt := 1; attempt <= maxAttempts; attempt++ {
if attempt > 1 {
backoff := time.Duration(attempt*attempt) * 10 * time.Second
logger.Warnf("存储目标 %s 第 %d 次重试(等待 %v%v", targetName, attempt, backoff, lastUploadErr)
time.Sleep(backoff)
}
s.logHub.AppendProgress(recordID, backup.ProgressInfo{
BytesSent: bytesRead,
TotalBytes: fileSize,
Percent: percent,
SpeedBps: speedBps,
TargetName: targetName,
artifact, openErr := os.Open(finalPath)
if openErr != nil {
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: openErr.Error()}
logger.Warnf("存储目标 %s 打开备份文件失败:%v", targetName, openErr)
return
}
hr = newHashingReader(artifact)
pr := newProgressReader(hr, fileSize, func(bytesRead int64, speedBps float64) {
percent := float64(0)
if fileSize > 0 {
percent = float64(bytesRead) / float64(fileSize) * 100
}
s.logHub.AppendProgress(recordID, backup.ProgressInfo{
BytesSent: bytesRead,
TotalBytes: fileSize,
Percent: percent,
SpeedBps: speedBps,
TargetName: targetName,
})
})
})
if uploadErr := provider.Upload(ctx, storagePath, pr, fileSize, map[string]string{"taskId": fmt.Sprintf("%d", task.ID), "recordId": fmt.Sprintf("%d", recordID)}); uploadErr != nil {
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: uploadErr.Error()}
logger.Warnf("存储目标 %s 上传失败:%v", targetName, uploadErr)
lastUploadErr = provider.Upload(ctx, storagePath, pr, fileSize, map[string]string{"taskId": fmt.Sprintf("%d", task.ID), "recordId": fmt.Sprintf("%d", recordID)})
artifact.Close()
if lastUploadErr == nil {
break
}
}
if lastUploadErr != nil {
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: lastUploadErr.Error()}
logger.Warnf("存储目标 %s 上传失败(已重试 %d 次):%v", targetName, maxAttempts, lastUploadErr)
return
}
// 完整性校验:对比实际传输字节数

View File

@@ -21,7 +21,7 @@ import (
type StorageTargetUpsertInput struct {
Name string `json:"name" binding:"required,min=1,max=128"`
Type string `json:"type" binding:"required,oneof=local_disk google_drive s3 webdav aliyun_oss tencent_cos qiniu_kodo ftp rclone"`
Type string `json:"type" binding:"required,min=1"`
Description string `json:"description" binding:"max=255"`
Enabled bool `json:"enabled"`
Config map[string]any `json:"config" binding:"required"`

View File

@@ -2,7 +2,14 @@ package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
@@ -30,6 +37,82 @@ func NewSystemService(cfg config.Config, version string, startedAt time.Time) *S
return &SystemService{cfg: cfg, version: version, startedAt: startedAt}
}
// UpdateCheckResult 描述版本更新检查结果。
type UpdateCheckResult struct {
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
HasUpdate bool `json:"hasUpdate"`
ReleaseURL string `json:"releaseUrl,omitempty"`
ReleaseNotes string `json:"releaseNotes,omitempty"`
PublishedAt string `json:"publishedAt,omitempty"`
DownloadURL string `json:"downloadUrl,omitempty"`
DockerImage string `json:"dockerImage,omitempty"`
}
const githubRepoAPI = "https://api.github.com/repos/Awuqing/BackupX/releases/latest"
// CheckUpdate 从 GitHub Releases 检查是否有新版本。
func (s *SystemService) CheckUpdate(ctx context.Context) (*UpdateCheckResult, error) {
result := &UpdateCheckResult{
CurrentVersion: s.version,
DockerImage: "awuqing/backupx",
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubRepoAPI, nil)
if err != nil {
return result, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", "BackupX/"+s.version)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return result, fmt.Errorf("fetch latest release: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return result, fmt.Errorf("github api returned %d", resp.StatusCode)
}
var release struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Body string `json:"body"`
Published string `json:"published_at"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return result, fmt.Errorf("decode release: %w", err)
}
result.LatestVersion = release.TagName
result.ReleaseURL = release.HTMLURL
result.ReleaseNotes = release.Body
result.PublishedAt = release.Published
// 比较版本号(去 v 前缀后字符串比较)
current := strings.TrimPrefix(s.version, "v")
latest := strings.TrimPrefix(release.TagName, "v")
result.HasUpdate = latest > current && current != "dev"
// 匹配当前平台的下载链接
goos := runtime.GOOS
goarch := runtime.GOARCH
suffix := fmt.Sprintf("%s-%s.tar.gz", goos, goarch)
for _, asset := range release.Assets {
if strings.HasSuffix(asset.Name, suffix) {
result.DownloadURL = asset.BrowserDownloadURL
break
}
}
return result, nil
}
func (s *SystemService) GetInfo(_ context.Context) *SystemInfo {
now := time.Now().UTC()
info := &SystemInfo{
@@ -51,3 +134,63 @@ func (s *SystemService) GetInfo(_ context.Context) *SystemInfo {
}
return info
}
// UpdateApplyResult 描述自动更新执行结果。
type UpdateApplyResult struct {
Success bool `json:"success"`
Message string `json:"message"`
Output string `json:"output,omitempty"`
}
// IsDockerEnvironment 检测当前是否运行在 Docker 容器中。
func (s *SystemService) IsDockerEnvironment() bool {
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
return false
}
// ApplyDockerUpdate 执行 Docker 自动更新pull 新镜像 + recreate 容器。
// 容器会在 docker compose up -d 后自动重启为新版本。
func (s *SystemService) ApplyDockerUpdate(_ context.Context, targetVersion string) *UpdateApplyResult {
if !s.IsDockerEnvironment() {
return &UpdateApplyResult{Success: false, Message: "当前非 Docker 环境,请手动下载二进制更新"}
}
image := "awuqing/backupx"
tag := strings.TrimSpace(targetVersion)
if tag == "" {
tag = "latest"
}
pullTarget := image + ":" + tag
// Step 1: docker pull
pullCmd := exec.Command("docker", "pull", pullTarget)
pullOut, pullErr := pullCmd.CombinedOutput()
if pullErr != nil {
return &UpdateApplyResult{Success: false, Message: fmt.Sprintf("docker pull 失败: %v", pullErr), Output: string(pullOut)}
}
// Step 2: docker compose up -d后台执行容器会自重启
// 检测 compose 命令
composeBin := "docker"
composeArgs := []string{"compose", "up", "-d"}
if _, err := exec.LookPath("docker-compose"); err == nil {
composeBin = "docker-compose"
composeArgs = []string{"up", "-d"}
}
// 异步执行,给 API 响应留时间
go func() {
time.Sleep(1 * time.Second)
cmd := exec.Command(composeBin, composeArgs...)
cmd.Dir = "/app" // Docker 容器中的工作目录
_ = cmd.Run()
}()
return &UpdateApplyResult{
Success: true,
Message: fmt.Sprintf("已拉取 %s容器即将自动重启到新版本", pullTarget),
Output: string(pullOut),
}
}

View File

@@ -434,3 +434,75 @@ type BackendOption struct {
Required bool `json:"required"`
IsPassword bool `json:"isPassword"`
}
// ---------------------------------------------------------------------------
// 通用 BackendFactory — 为任意 rclone 后端自动生成独立 Factory
// ---------------------------------------------------------------------------
// GenericBackendFactory 为单个 rclone 后端创建独立的 ProviderFactory。
// 用户存储目标的 type 直接是后端名(如 "sftp"),与 "s3"、"ftp" 完全平级。
type GenericBackendFactory struct {
backendType string
sensitive []string
}
// NewBackendFactory 为指定 rclone 后端创建一个 Factory。
func NewBackendFactory(backendType string) GenericBackendFactory {
var sensitive []string
for _, ri := range fs.Registry {
if ri.Name == backendType {
for _, opt := range ri.Options {
if opt.IsPassword {
sensitive = append(sensitive, opt.Name)
}
}
break
}
}
return GenericBackendFactory{backendType: backendType, sensitive: sensitive}
}
func (f GenericBackendFactory) Type() storage.ProviderType { return storage.ProviderType(f.backendType) }
func (f GenericBackendFactory) SensitiveFields() []string { return f.sensitive }
func (f GenericBackendFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
root, _ := rawConfig["root"].(string)
root = strings.TrimSpace(root)
var b strings.Builder
b.WriteString(":")
b.WriteString(f.backendType)
for key, val := range rawConfig {
if key == "root" {
continue
}
strVal := fmt.Sprintf("%v", val)
if strings.TrimSpace(strVal) == "" {
continue
}
b.WriteString(",")
b.WriteString(key)
b.WriteString("=")
b.WriteString(quoteParam(strVal))
}
b.WriteString(":")
b.WriteString(root)
return newFs(ctx, storage.ProviderType(f.backendType), b.String())
}
// RegisterAllBackends 将所有 rclone 后端注册为独立 Factory 到 Registry。
// 已存在的内置类型s3, ftp 等)不会被覆盖。
func RegisterAllBackends(registry *storage.Registry) {
builtinTypes := map[string]bool{
"local_disk": true, "s3": true, "webdav": true, "google_drive": true,
"ftp": true, "aliyun_oss": true, "tencent_cos": true, "qiniu_kodo": true,
"rclone": true, "local": true,
}
for _, info := range ListBackends() {
if builtinTypes[info.Name] {
continue
}
registry.Register(NewBackendFactory(info.Name))
}
}

View File

@@ -1,6 +1,6 @@
import { Alert, Button, Divider, Drawer, Input, Select, Space, Switch, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
import { getStorageTargetFieldConfigs, getStorageTargetTypeLabel, storageTargetTypeOptions } from './field-config'
import { getStorageTargetFieldConfigs, getStorageTargetTypeLabel, isBuiltinType, builtinTypeOptions } from './field-config'
import type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetType } from '../../types/storage-targets'
import { listRcloneBackends, type RcloneBackendInfo } from '../../services/rclone'
@@ -16,37 +16,29 @@ interface StorageTargetFormDrawerProps {
}
function createEmptyDraft(type: StorageTargetType = 'local_disk'): StorageTargetPayload {
return {
name: '',
type,
description: '',
enabled: true,
config: {},
}
return { name: '', type, description: '', enabled: true, config: {} }
}
export function StorageTargetFormDrawer({
visible,
loading,
testing,
initialValue,
onCancel,
onSubmit,
onTest,
onGoogleDriveAuth,
visible, loading, testing, initialValue, onCancel, onSubmit, onTest, onGoogleDriveAuth,
}: StorageTargetFormDrawerProps) {
const [draft, setDraft] = useState<StorageTargetPayload>(createEmptyDraft())
const [error, setError] = useState('')
const [testResult, setTestResult] = useState<StorageConnectionTestResult | null>(null)
// rclone 后端列表API 驱动)
const [rcloneBackends, setRcloneBackends] = useState<RcloneBackendInfo[]>([])
const [rcloneBackendsLoading, setRcloneBackendsLoading] = useState(false)
const [backendsLoaded, setBackendsLoaded] = useState(false)
// 加载 rclone 后端列表
useEffect(() => {
if (visible && !backendsLoaded) {
listRcloneBackends()
.then((data) => { setRcloneBackends(data); setBackendsLoaded(true) })
.catch(() => setBackendsLoaded(true))
}
}, [visible, backendsLoaded])
useEffect(() => {
if (!visible) {
return
}
if (!visible) return
if (!initialValue) {
setDraft(createEmptyDraft())
setError('')
@@ -64,256 +56,137 @@ export function StorageTargetFormDrawer({
setTestResult(null)
}, [initialValue, visible])
// 当类型切换到 rclone 时,加载后端列表
useEffect(() => {
if (draft.type === 'rclone' && rcloneBackends.length === 0 && !rcloneBackendsLoading) {
setRcloneBackendsLoading(true)
listRcloneBackends()
.then(setRcloneBackends)
.catch(() => {})
.finally(() => setRcloneBackendsLoading(false))
}
}, [draft.type, rcloneBackends.length, rcloneBackendsLoading])
const fieldConfigs = useMemo(() => getStorageTargetFieldConfigs(draft.type), [draft.type])
// 当前选中的 rclone 后端信息
const selectedRcloneBackend = useMemo(() => {
if (draft.type !== 'rclone') return null
const backendName = draft.config.backend as string
if (!backendName) return null
return rcloneBackends.find((b) => b.name === backendName) || null
}, [draft.type, draft.config.backend, rcloneBackends])
// rclone 后端下拉选项
const rcloneBackendOptions = useMemo(() => {
return rcloneBackends.map((b) => ({
label: `${b.name}${b.description}`,
value: b.name,
}))
// 合并类型选项:内置 + 全部 rclone 后端
const allTypeOptions = useMemo(() => {
const builtinValues = new Set(builtinTypeOptions.map((o) => o.value))
const rcloneOptions = rcloneBackends
.filter((b) => !builtinValues.has(b.name) && b.name !== 'local' && b.name !== 'rclone')
.map((b) => ({ label: `${b.name.toUpperCase()}${b.description}`, value: b.name }))
return [
...builtinTypeOptions.map((o) => ({ ...o, label: o.label, value: o.value as string })),
...rcloneOptions,
]
}, [rcloneBackends])
// 当前类型是否为非内置rclone 动态后端)
const isDynamicType = !isBuiltinType(draft.type)
const staticFields = isBuiltinType(draft.type) ? getStorageTargetFieldConfigs(draft.type) : []
// 当前 rclone 后端的动态字段
const dynamicBackend = useMemo(() => {
if (!isDynamicType) return null
return rcloneBackends.find((b) => b.name === draft.type) || null
}, [isDynamicType, draft.type, rcloneBackends])
function updateConfig(key: string, value: string | boolean) {
setDraft((current) => ({
...current,
config: {
...current.config,
[key]: value,
},
}))
setDraft((c) => ({ ...c, config: { ...c.config, [key]: value } }))
}
function validate(value: StorageTargetPayload) {
if (!value.name.trim()) {
return '请输入存储目标名称'
}
// rclone 类型需要选择后端
if (value.type === 'rclone') {
if (!value.config.backend || !(value.config.backend as string).trim()) {
return '请选择 Rclone 后端类型'
}
return ''
}
for (const field of fieldConfigs) {
if (!field.required) {
continue
}
const currentValue = value.config[field.key]
if (field.type === 'switch') {
continue
}
if (typeof currentValue !== 'string' || !currentValue.trim()) {
return `请填写${field.label}`
if (!value.name.trim()) return '请输入存储目标名称'
if (!value.type.trim()) return '请选择存储类型'
if (isBuiltinType(value.type)) {
for (const field of staticFields) {
if (!field.required || field.type === 'switch') continue
const v = value.config[field.key]
if (typeof v !== 'string' || !v.trim()) return `请填写${field.label}`
}
}
return ''
}
async function handleSubmit() {
const validationError = validate(draft)
if (validationError) {
setError(validationError)
return
}
setError('')
await onSubmit(draft, initialValue?.id)
const e = validate(draft); if (e) { setError(e); return }
setError(''); await onSubmit(draft, initialValue?.id)
}
async function handleTest() {
const validationError = validate(draft)
if (validationError) {
setError(validationError)
return
}
setError('')
const result = await onTest(draft, initialValue?.id)
setTestResult(result)
const e = validate(draft); if (e) { setError(e); return }
setError(''); setTestResult(await onTest(draft, initialValue?.id))
}
async function handleGoogleDriveAuth() {
const validationError = validate(draft)
if (validationError) {
setError(validationError)
return
}
setError('')
await onGoogleDriveAuth(draft, initialValue?.id)
const e = validate(draft); if (e) { setError(e); return }
setError(''); await onGoogleDriveAuth(draft, initialValue?.id)
}
// 渲染 rclone 类型的动态配置表单
function renderRcloneFields() {
return (
<>
<div>
<Typography.Text>Rclone *</Typography.Text>
<Select
showSearch
allowClear
placeholder="搜索并选择后端(如 sftp, azureblob, dropbox..."
loading={rcloneBackendsLoading}
value={(draft.config.backend as string) || undefined}
options={rcloneBackendOptions}
filterOption={(inputValue, option) => {
const label = (option?.props?.children ?? option?.props?.label ?? '') as string
return label.toLowerCase().includes(inputValue.toLowerCase())
}}
onChange={(value) => {
// 切换后端时清空旧配置,保留 backend 和 root
const root = draft.config.root || ''
setDraft((current) => ({
...current,
config: { backend: value || '', root },
}))
}}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
SFTPAzure BlobDropboxOneDriveB2SMB 70+
</Typography.Paragraph>
</div>
<div>
<Typography.Text></Typography.Text>
<Input
value={(draft.config.root as string) || ''}
placeholder="/backups 或 bucket-name"
onChange={(value) => updateConfig('root', value)}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
使
</Typography.Paragraph>
</div>
{selectedRcloneBackend && selectedRcloneBackend.options.length > 0 && (
<>
<Divider orientation="left" style={{ margin: '8px 0' }}>
{selectedRcloneBackend.name}
</Divider>
{selectedRcloneBackend.options.map((opt) => (
<div key={opt.key}>
<Typography.Text>
{opt.key}
{opt.required ? ' *' : ''}
</Typography.Text>
{opt.isPassword ? (
<Input.Password
value={(draft.config[opt.key] as string) || ''}
placeholder={opt.label}
onChange={(value) => updateConfig(opt.key, value)}
/>
) : (
<Input
value={(draft.config[opt.key] as string) || ''}
placeholder={opt.label}
onChange={(value) => updateConfig(opt.key, value)}
/>
)}
{opt.label && (
<Typography.Paragraph
type="secondary"
style={{ marginBottom: 0, marginTop: 2, fontSize: 12, lineHeight: '18px' }}
ellipsis={{ rows: 2, expandable: true }}
>
{opt.label}
</Typography.Paragraph>
)}
</div>
))}
</>
)}
</>
)
}
// 渲染常规类型的静态字段
// 渲染静态字段(内置类型)
function renderStaticFields() {
return fieldConfigs.map((field) => {
return staticFields.map((field) => {
const value = draft.config[field.key]
const normalizedValue = typeof value === 'boolean' ? value : typeof value === 'string' ? value : field.type === 'switch' ? false : ''
const normalized = typeof value === 'boolean' ? value : typeof value === 'string' ? value : field.type === 'switch' ? false : ''
return (
<div key={field.key}>
<Typography.Text>
{field.label}
{field.required ? ' *' : ''}
</Typography.Text>
<Typography.Text>{field.label}{field.required ? ' *' : ''}</Typography.Text>
{field.type === 'switch' ? (
<Space align="center" size="medium">
<Switch checked={Boolean(normalizedValue)} onChange={(checked) => updateConfig(field.key, checked)} />
{field.description ? <Typography.Text type="secondary">{field.description}</Typography.Text> : null}
<Switch checked={Boolean(normalized)} onChange={(v) => updateConfig(field.key, v)} />
{field.description && <Typography.Text type="secondary">{field.description}</Typography.Text>}
</Space>
) : field.type === 'password' ? (
<Input.Password
value={String(normalizedValue)}
placeholder={field.placeholder}
onChange={(nextValue) => updateConfig(field.key, nextValue)}
/>
<Input.Password value={String(normalized)} placeholder={field.placeholder} onChange={(v) => updateConfig(field.key, v)} />
) : (
<Input value={String(normalizedValue)} placeholder={field.placeholder} onChange={(nextValue) => updateConfig(field.key, nextValue)} />
<Input value={String(normalized)} placeholder={field.placeholder} onChange={(v) => updateConfig(field.key, v)} />
)}
{field.description && field.type !== 'switch' && (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>{field.description}</Typography.Paragraph>
)}
{initialValue?.maskedFields?.includes(field.key) && !draft.config[field.key] && (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}></Typography.Paragraph>
)}
{field.description && field.type !== 'switch' ? (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
{field.description}
</Typography.Paragraph>
) : null}
{initialValue?.maskedFields?.includes(field.key) && !draft.config[field.key] ? (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
</Typography.Paragraph>
) : null}
</div>
)
})
}
// 渲染动态字段rclone 后端)
function renderDynamicFields() {
return (
<>
<div>
<Typography.Text></Typography.Text>
<Input value={(draft.config.root as string) || ''} placeholder="如 /backups 或 bucket 名" onChange={(v) => updateConfig('root', v)} />
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>使</Typography.Paragraph>
</div>
{dynamicBackend && dynamicBackend.options.length > 0 && dynamicBackend.options.map((opt) => (
<div key={opt.key}>
<Typography.Text>{opt.key}{opt.required ? ' *' : ''}</Typography.Text>
{opt.isPassword ? (
<Input.Password value={(draft.config[opt.key] as string) || ''} placeholder={opt.label} onChange={(v) => updateConfig(opt.key, v)} />
) : (
<Input value={(draft.config[opt.key] as string) || ''} placeholder={opt.label} onChange={(v) => updateConfig(opt.key, v)} />
)}
{opt.label && (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 2, fontSize: 12 }} ellipsis={{ rows: 2, expandable: true }}>{opt.label}</Typography.Paragraph>
)}
</div>
))}
</>
)
}
return (
<Drawer
width={560}
title={initialValue ? '编辑存储目标' : '新建存储目标'}
visible={visible}
onCancel={onCancel}
unmountOnExit={false}
>
<Drawer width={560} title={initialValue ? '编辑存储目标' : '新建存储目标'} visible={visible} onCancel={onCancel} unmountOnExit={false}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{error ? <Alert type="error" content={error} /> : <Alert type="info" content="存储目标提供备份文件的最终去向,请确保服务端网络连通性并通过测试。" />}
{testResult ? <Alert type={testResult.success ? 'success' : 'warning'} content={testResult.message} /> : null}
{testResult && <Alert type={testResult.success ? 'success' : 'warning'} content={testResult.message} />}
<div>
<Typography.Text></Typography.Text>
<Input value={draft.name} placeholder="例如:生产环境 MinIO" onChange={(value) => setDraft((current) => ({ ...current, name: value }))} />
<Input value={draft.name} placeholder="例如:生产环境 MinIO" onChange={(v) => setDraft((c) => ({ ...c, name: v }))} />
</div>
<div>
<Typography.Text></Typography.Text>
<Typography.Text></Typography.Text>
<Select
value={draft.type}
options={storageTargetTypeOptions as unknown as { label: string; value: string }[]}
showSearch
value={draft.type || undefined}
placeholder="搜索存储类型(如 SFTP、Azure Blob、Dropbox..."
options={allTypeOptions}
filterOption={(input, option) => {
const label = (option?.props?.children ?? option?.props?.label ?? '') as string
return label.toLowerCase().includes(input.toLowerCase())
}}
onChange={(value) => {
const nextType = value as StorageTargetType
setDraft((current) => ({
...current,
type: nextType,
config: {},
}))
setDraft((c) => ({ ...c, type: value as string, config: {} }))
setTestResult(null)
}}
/>
@@ -321,16 +194,12 @@ export function StorageTargetFormDrawer({
<div>
<Typography.Text></Typography.Text>
<Input.TextArea
value={draft.description}
placeholder="可选描述,例如备份上传到 NAS 或 Google Drive"
onChange={(value) => setDraft((current) => ({ ...current, description: value }))}
/>
<Input.TextArea value={draft.description} placeholder="可选描述" onChange={(v) => setDraft((c) => ({ ...c, description: v }))} />
</div>
<Space align="center" size="medium">
<Typography.Text></Typography.Text>
<Switch checked={draft.enabled} onChange={(checked) => setDraft((current) => ({ ...current, enabled: checked }))} />
<Switch checked={draft.enabled} onChange={(v) => setDraft((c) => ({ ...c, enabled: v }))} />
</Space>
<Divider orientation="left"></Divider>
@@ -340,22 +209,18 @@ export function StorageTargetFormDrawer({
{getStorageTargetTypeLabel(draft.type)}
</Typography.Title>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{draft.type === 'rclone' ? renderRcloneFields() : renderStaticFields()}
{isDynamicType ? renderDynamicFields() : renderStaticFields()}
</Space>
</div>
<Space>
<Button loading={testing} onClick={handleTest}>
</Button>
{draft.type === 'google_drive' ? (
<Button loading={testing} onClick={handleTest}></Button>
{draft.type === 'google_drive' && (
<Button type="outline" onClick={handleGoogleDriveAuth}>
{initialValue ? '重新授权 Google Drive' : '发起 Google Drive 授权'}
</Button>
) : null}
<Button type="primary" loading={loading} onClick={handleSubmit}>
</Button>
)}
<Button type="primary" loading={loading} onClick={handleSubmit}></Button>
</Space>
</Space>
</Drawer>

View File

@@ -1,298 +1,82 @@
import type { StorageTargetFieldConfig, StorageTargetType } from '../../types/storage-targets'
const FIELD_CONFIG_MAP: Record<StorageTargetType, StorageTargetFieldConfig[]> = {
// 内置类型的静态字段配置(定制化配置结构)
const BUILTIN_FIELD_CONFIG: Record<string, StorageTargetFieldConfig[]> = {
local_disk: [
{
key: 'basePath',
label: '基础目录',
type: 'input',
required: true,
placeholder: '/data/backups',
description: 'BackupX 将在该目录下创建和管理备份文件。',
},
{ key: 'basePath', label: '基础目录', type: 'input', required: true, placeholder: '/data/backups', description: 'BackupX 将在该目录下创建和管理备份文件。' },
],
s3: [
{
key: 'endpoint',
label: 'Endpoint',
type: 'input',
required: true,
placeholder: 'https://s3.amazonaws.com',
},
{
key: 'region',
label: '区域',
type: 'input',
required: true,
placeholder: 'ap-east-1',
},
{
key: 'bucket',
label: 'Bucket',
type: 'input',
required: true,
placeholder: 'backupx-prod',
},
{
key: 'accessKeyId',
label: 'Access Key ID',
type: 'input',
required: true,
sensitive: true,
placeholder: 'AKIA...',
},
{
key: 'secretAccessKey',
label: 'Secret Access Key',
type: 'password',
required: true,
sensitive: true,
placeholder: '输入新的 Secret Access Key',
},
{
key: 'forcePathStyle',
label: '强制 Path Style',
type: 'switch',
description: 'MinIO 或部分兼容对象存储通常需要开启。',
},
{ key: 'endpoint', label: 'Endpoint', type: 'input', required: true, placeholder: 'https://s3.amazonaws.com' },
{ key: 'region', label: '区域', type: 'input', required: true, placeholder: 'ap-east-1' },
{ key: 'bucket', label: 'Bucket', type: 'input', required: true, placeholder: 'backupx-prod' },
{ key: 'accessKeyId', label: 'Access Key ID', type: 'input', required: true, sensitive: true, placeholder: 'AKIA...' },
{ key: 'secretAccessKey', label: 'Secret Access Key', type: 'password', required: true, sensitive: true },
{ key: 'forcePathStyle', label: '强制 Path Style', type: 'switch', description: 'MinIO 等兼容存储需要开启。' },
],
webdav: [
{
key: 'endpoint',
label: 'WebDAV 地址',
type: 'input',
required: true,
placeholder: 'https://dav.example.com/remote.php/dav/files/admin',
},
{
key: 'username',
label: '用户名',
type: 'input',
required: true,
placeholder: 'admin',
},
{
key: 'password',
label: '密码',
type: 'password',
required: true,
sensitive: true,
placeholder: '输入新的 WebDAV 密码',
},
{
key: 'basePath',
label: '基础目录',
type: 'input',
placeholder: '/backupx',
},
{ key: 'endpoint', label: 'WebDAV 地址', type: 'input', required: true, placeholder: 'https://dav.example.com/...' },
{ key: 'username', label: '用户名', type: 'input', required: true },
{ key: 'password', label: '密码', type: 'password', required: true, sensitive: true },
{ key: 'basePath', label: '基础目录', type: 'input', placeholder: '/backupx' },
],
google_drive: [
{
key: 'clientId',
label: 'Client ID',
type: 'input',
required: true,
sensitive: true,
placeholder: 'Google OAuth Client ID',
},
{
key: 'clientSecret',
label: 'Client Secret',
type: 'password',
required: true,
sensitive: true,
placeholder: '输入新的 Google Client Secret',
},
{
key: 'folderId',
label: '目标文件夹 ID',
type: 'input',
placeholder: '留空则使用根目录',
},
{ key: 'clientId', label: 'Client ID', type: 'input', required: true, sensitive: true },
{ key: 'clientSecret', label: 'Client Secret', type: 'password', required: true, sensitive: true },
{ key: 'folderId', label: '目标文件夹 ID', type: 'input', placeholder: '留空则使用根目录' },
],
aliyun_oss: [
{
key: 'region',
label: '区域 (Region)',
type: 'input',
required: true,
placeholder: 'cn-hangzhou',
description: '如 cn-hangzhou, cn-shanghai, cn-beijing, cn-shenzhen 等。系统会自动组装 Endpoint。',
},
{
key: 'bucket',
label: 'Bucket',
type: 'input',
required: true,
placeholder: 'my-backup-bucket',
},
{
key: 'accessKeyId',
label: 'AccessKey ID',
type: 'input',
required: true,
sensitive: true,
placeholder: 'LTAI...',
},
{
key: 'secretAccessKey',
label: 'AccessKey Secret',
type: 'password',
required: true,
sensitive: true,
placeholder: '输入新的 AccessKey Secret',
},
{
key: 'internalNetwork',
label: '使用内网 Endpoint',
type: 'switch',
description: '同一区域的 ECS 实例可启用内网传输,节省流量费用。',
},
{ key: 'region', label: '区域', type: 'input', required: true, placeholder: 'cn-hangzhou' },
{ key: 'bucket', label: 'Bucket', type: 'input', required: true },
{ key: 'accessKeyId', label: 'AccessKey ID', type: 'input', required: true, sensitive: true },
{ key: 'secretAccessKey', label: 'AccessKey Secret', type: 'password', required: true, sensitive: true },
{ key: 'internalNetwork', label: '使用内网', type: 'switch' },
],
tencent_cos: [
{
key: 'region',
label: '区域 (Region)',
type: 'input',
required: true,
placeholder: 'ap-guangzhou',
description: '如 ap-guangzhou, ap-shanghai, ap-beijing, ap-chengdu 等。系统会自动组装 Endpoint。',
},
{
key: 'bucket',
label: 'Bucket',
type: 'input',
required: true,
placeholder: 'backup-1250000000',
description: '格式为 BucketName-APPID如 backup-1250000000。',
},
{
key: 'accessKeyId',
label: 'SecretId',
type: 'input',
required: true,
sensitive: true,
placeholder: 'AKIDxxxxxxxx',
},
{
key: 'secretAccessKey',
label: 'SecretKey',
type: 'password',
required: true,
sensitive: true,
placeholder: '输入新的 SecretKey',
},
{ key: 'region', label: '区域', type: 'input', required: true, placeholder: 'ap-guangzhou' },
{ key: 'bucket', label: 'Bucket', type: 'input', required: true, placeholder: 'backup-1250000000' },
{ key: 'accessKeyId', label: 'SecretId', type: 'input', required: true, sensitive: true },
{ key: 'secretAccessKey', label: 'SecretKey', type: 'password', required: true, sensitive: true },
],
qiniu_kodo: [
{
key: 'region',
label: '区域 (Region)',
type: 'input',
required: true,
placeholder: 'z0',
description: '支持 z0(华东), cn-east-2(华东-浙江2), z1(华北), z2(华南), na0(北美), as0(东南亚)。',
},
{
key: 'bucket',
label: 'Bucket',
type: 'input',
required: true,
placeholder: 'my-backup',
},
{
key: 'accessKeyId',
label: 'AccessKey',
type: 'input',
required: true,
sensitive: true,
placeholder: '七牛云 AccessKey',
},
{
key: 'secretAccessKey',
label: 'SecretKey',
type: 'password',
required: true,
sensitive: true,
placeholder: '输入新的 SecretKey',
},
{ key: 'region', label: '区域', type: 'input', required: true, placeholder: 'z0' },
{ key: 'bucket', label: 'Bucket', type: 'input', required: true },
{ key: 'accessKeyId', label: 'AccessKey', type: 'input', required: true, sensitive: true },
{ key: 'secretAccessKey', label: 'SecretKey', type: 'password', required: true, sensitive: true },
],
rclone: [], // 动态表单,字段从 API 获取(见 StorageTargetFormDrawer
ftp: [
{
key: 'host',
label: '主机地址',
type: 'input',
required: true,
placeholder: 'ftp.example.com',
},
{
key: 'port',
label: '端口',
type: 'input',
placeholder: '21',
description: '默认 FTP 端口为 21。',
},
{
key: 'username',
label: '用户名',
type: 'input',
required: true,
placeholder: 'backup_user',
},
{
key: 'password',
label: '密码',
type: 'password',
required: true,
sensitive: true,
placeholder: '输入新的 FTP 密码',
},
{
key: 'basePath',
label: '基础目录',
type: 'input',
placeholder: '/backups',
description: 'FTP 服务器上的目标目录,留空使用根目录。',
},
{
key: 'useTLS',
label: '使用 TLS (FTPS)',
type: 'switch',
description: '启用 Explicit TLS 加密连接。',
},
{ key: 'host', label: '主机地址', type: 'input', required: true, placeholder: 'ftp.example.com' },
{ key: 'port', label: '端口', type: 'input', placeholder: '21' },
{ key: 'username', label: '用户名', type: 'input', required: true },
{ key: 'password', label: '密码', type: 'password', required: true, sensitive: true },
{ key: 'basePath', label: '基础目录', type: 'input', placeholder: '/backups' },
{ key: 'useTLS', label: 'TLS (FTPS)', type: 'switch' },
],
}
export function getStorageTargetFieldConfigs(type: StorageTargetType) {
return FIELD_CONFIG_MAP[type]
const BUILTIN_TYPES = new Set(Object.keys(BUILTIN_FIELD_CONFIG))
/** 是否为内置类型 */
export function isBuiltinType(type: StorageTargetType): boolean {
return BUILTIN_TYPES.has(type)
}
export function getStorageTargetTypeLabel(type: StorageTargetType) {
switch (type) {
case 'local_disk':
return '本地磁盘'
case 'google_drive':
return 'Google Drive'
case 's3':
return 'S3 Compatible'
case 'webdav':
return 'WebDAV'
case 'aliyun_oss':
return '阿里云 OSS'
case 'tencent_cos':
return '腾讯云 COS'
case 'qiniu_kodo':
return '七牛云 Kodo'
case 'ftp':
return 'FTP'
case 'rclone':
return 'Rclone (70+ 后端)'
default:
return type
}
/** 获取静态字段配置 */
export function getStorageTargetFieldConfigs(type: StorageTargetType): StorageTargetFieldConfig[] {
return BUILTIN_FIELD_CONFIG[type] ?? []
}
export const storageTargetTypeOptions = [
const BUILTIN_LABELS: Record<string, string> = {
local_disk: '本地磁盘', google_drive: 'Google Drive', s3: 'S3 Compatible',
webdav: 'WebDAV', aliyun_oss: '阿里云 OSS', tencent_cos: '腾讯云 COS',
qiniu_kodo: '七牛云 Kodo', ftp: 'FTP', rclone: 'Rclone',
}
export function getStorageTargetTypeLabel(type: StorageTargetType): string {
return BUILTIN_LABELS[type] || type.toUpperCase()
}
/** 内置类型选项(下拉框"常用"分组) */
export const builtinTypeOptions = [
{ label: '本地磁盘', value: 'local_disk' },
{ label: '阿里云 OSS', value: 'aliyun_oss' },
{ label: '腾讯云 COS', value: 'tencent_cos' },
@@ -301,5 +85,4 @@ export const storageTargetTypeOptions = [
{ label: 'Google Drive', value: 'google_drive' },
{ label: 'WebDAV', value: 'webdav' },
{ label: 'FTP', value: 'ftp' },
{ label: 'Rclone — SFTP / Azure / Dropbox / OneDrive 等 70+ 后端', value: 'rclone' },
] as const
]

View File

@@ -1,88 +1,159 @@
import { Card, Descriptions, Grid, PageHeader, Space, Typography } from '@arco-design/web-react'
import { Badge, Button, Card, Descriptions, Grid, Link, Message, PageHeader, Space, Tag, Typography } from '@arco-design/web-react'
import { useEffect, useState } from 'react'
import { fetchSystemInfo, type SystemInfo } from '../../services/system'
import { fetchSystemInfo, checkUpdate, applyUpdate, type SystemInfo, type UpdateCheckResult } from '../../services/system'
import { resolveErrorMessage } from '../../utils/error'
import { formatDuration } from '../../utils/format'
const { Row, Col } = Grid
const deploySteps = [
'1. 构建前端cd web && npm run build',
'2. 编译后端cd server && go build -o backupx ./cmd/backupx',
'3. 部署静态资源与二进制,并按 deploy/ 目录提供的配置接入 Nginx 与 systemd',
'4. 首次启动后访问 Web 控制台,完成管理员初始化与存储目标配置',
]
function formatBytes(bytes: number | undefined): string {
if (!bytes || bytes <= 0) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let i = 0
let size = bytes
while (size >= 1024 && i < units.length - 1) {
size /= 1024
i++
}
return `${size.toFixed(1)} ${units[i]}`
}
export function SettingsPage() {
const [info, setInfo] = useState<SystemInfo | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [updateResult, setUpdateResult] = useState<UpdateCheckResult | null>(null)
const [checking, setChecking] = useState(false)
const [applying, setApplying] = useState(false)
useEffect(() => {
let active = true
void (async () => {
try {
const result = await fetchSystemInfo()
if (active) {
setInfo(result)
setError('')
}
if (active) { setInfo(result); setError('') }
} catch (loadError) {
if (active) {
setError(resolveErrorMessage(loadError, '加载系统设置失败'))
}
if (active) setError(resolveErrorMessage(loadError, '加载系统信息失败'))
} finally {
if (active) {
setLoading(false)
}
if (active) setLoading(false)
}
})()
return () => {
active = false
}
return () => { active = false }
}, [])
async function handleCheckUpdate() {
setChecking(true)
try {
const result = await checkUpdate()
setUpdateResult(result)
} catch (e) {
setUpdateResult({ currentVersion: info?.version || '-', latestVersion: '-', hasUpdate: false, error: resolveErrorMessage(e, '检查更新失败') })
} finally {
setChecking(false)
}
}
async function handleApplyUpdate() {
if (!updateResult?.latestVersion) return
setApplying(true)
try {
const result = await applyUpdate(updateResult.latestVersion)
if (result.success) {
Message.success('更新已触发,容器即将自动重启...')
setTimeout(() => Message.info('请等待 10-30 秒后刷新页面'), 3000)
} else {
Message.warning(result.message)
}
} catch (e) {
Message.error(resolveErrorMessage(e, '触发更新失败'))
} finally {
setApplying(false)
}
}
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<PageHeader
style={{ paddingBottom: 16 }}
title="系统设置"
subTitle="展示当前运行信息、部署入口和交付所需的基础操作说明"
>
<PageHeader style={{ paddingBottom: 16 }} title="系统设置" subTitle="运行信息、磁盘状态与版本更新">
{error ? <Typography.Text type="error">{error}</Typography.Text> : null}
</PageHeader>
<Row gutter={16}>
<Col span={12}>
<Card loading={loading} title="运行信息">
<Descriptions
column={1}
border
data={[
{ label: '版本', value: info?.version ?? '-' },
{ label: '运行模式', value: info?.mode ?? '-' },
{ label: '运行时长', value: formatDuration(info?.uptimeSeconds) },
{ label: '启动时间', value: info?.startedAt ?? '-' },
{ label: '数据库路径', value: info?.databasePath ?? '-' },
]}
/>
<Descriptions column={1} border data={[
{ label: '版本', value: <Space>{info?.version ?? '-'}<Button size="mini" type="text" loading={checking} onClick={handleCheckUpdate}></Button></Space> },
{ label: '运行模式', value: info?.mode === 'release' ? <Tag color="green"></Tag> : <Tag color="orange">{info?.mode ?? '-'}</Tag> },
{ label: '运行时长', value: formatDuration(info?.uptimeSeconds) },
{ label: '启动时间', value: info?.startedAt ?? '-' },
{ label: '数据库路径', value: <Typography.Text copyable>{info?.databasePath ?? '-'}</Typography.Text> },
]} />
</Card>
</Col>
<Col span={12}>
<Card title="部署资产">
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Typography.Text>`deploy/nginx.conf` `/api` </Typography.Text>
<Typography.Text>`deploy/backupx.service`systemd API </Typography.Text>
<Typography.Text>`deploy/install.sh`</Typography.Text>
<Typography.Text>`README.md`使</Typography.Text>
</Space>
<Card loading={loading} title="磁盘状态">
<Descriptions column={1} border data={[
{ label: '总空间', value: formatBytes(info?.diskTotal) },
{ label: '已用空间', value: formatBytes(info?.diskUsed) },
{ label: '可用空间', value: formatBytes(info?.diskFree) },
{ label: '使用率', value: info?.diskTotal ? `${((info.diskUsed / info.diskTotal) * 100).toFixed(1)}%` : '-' },
]} />
</Card>
</Col>
</Row>
<Card title="部署步骤">
<div className="code-block">{deploySteps.join('\n')}</div>
</Card>
{/* 更新检查结果 */}
{updateResult && (
<Card title="版本更新">
{updateResult.error ? (
<Typography.Text type="warning">{updateResult.error}</Typography.Text>
) : updateResult.hasUpdate ? (
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Space>
<Badge status="processing" />
<Typography.Text style={{ fontWeight: 600 }}>
{updateResult.latestVersion}
</Typography.Text>
<Typography.Text type="secondary">{updateResult.currentVersion}</Typography.Text>
</Space>
{updateResult.publishedAt && (
<Typography.Text type="secondary">{new Date(updateResult.publishedAt).toLocaleString()}</Typography.Text>
)}
{updateResult.releaseNotes && (
<Card size="small" title="更新说明" style={{ maxHeight: 200, overflow: 'auto' }}>
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{updateResult.releaseNotes}</Typography.Paragraph>
</Card>
)}
<Space>
<Button type="primary" status="success" loading={applying} onClick={handleApplyUpdate}>
Docker
</Button>
{updateResult.downloadUrl && (
<Link href={updateResult.downloadUrl} target="_blank">
<Button type="outline"></Button>
</Link>
)}
{updateResult.releaseUrl && (
<Link href={updateResult.releaseUrl} target="_blank">
<Button type="text">Release </Button>
</Link>
)}
</Space>
{updateResult.dockerImage && (
<Card size="small" title="Docker 更新命令">
<Typography.Paragraph copyable code style={{ marginBottom: 0 }}>
{`docker pull ${updateResult.dockerImage}:${updateResult.latestVersion} && docker compose up -d`}
</Typography.Paragraph>
</Card>
)}
</Space>
) : (
<Space>
<Badge status="success" />
<Typography.Text> ({updateResult.currentVersion})</Typography.Text>
</Space>
)}
</Card>
)}
</Space>
)
}

View File

@@ -14,6 +14,6 @@ export interface RcloneBackendInfo {
}
export async function listRcloneBackends(): Promise<RcloneBackendInfo[]> {
const { data } = await http.get<{ data: RcloneBackendInfo[] }>('/api/storage-targets/rclone/backends')
const { data } = await http.get<{ data: RcloneBackendInfo[] }>('/storage-targets/rclone/backends')
return data.data
}

View File

@@ -11,11 +11,39 @@ export interface SystemInfo {
diskUsed: number
}
export interface UpdateCheckResult {
currentVersion: string
latestVersion: string
hasUpdate: boolean
releaseUrl?: string
releaseNotes?: string
publishedAt?: string
downloadUrl?: string
dockerImage?: string
error?: string
}
export async function fetchSystemInfo() {
const response = await http.get<{ code: string; message: string; data: SystemInfo }>('/system/info')
return response.data.data
}
export async function checkUpdate() {
const response = await http.get<{ code: string; message: string; data: UpdateCheckResult }>('/system/update-check')
return response.data.data
}
export interface UpdateApplyResult {
success: boolean
message: string
output?: string
}
export async function applyUpdate(version: string) {
const response = await http.post<{ code: string; message: string; data: UpdateApplyResult }>('/system/update-apply', { version })
return response.data.data
}
export async function fetchSettings() {
const response = await http.get<{ code: string; message: string; data: Record<string, string> }>('/settings')
return response.data.data

View File

@@ -1,4 +1,5 @@
export type StorageTargetType = 'local_disk' | 'google_drive' | 's3' | 'webdav' | 'aliyun_oss' | 'tencent_cos' | 'qiniu_kodo' | 'ftp' | 'rclone'
// 内置类型 + 全部 rclone 后端名sftp, azureblob, dropbox 等)
export type StorageTargetType = string
export type StorageTestStatus = 'unknown' | 'success' | 'failed'
export type StorageFieldType = 'input' | 'password' | 'switch'