Compare commits

...

21 Commits

Author SHA1 Message Date
Wu Qing
d26753c44a 优化: 存储类型下拉框分类中文标注去重 (#33)
优化: 存储类型下拉框分类中文标注去重
2026-04-02 13:43:37 +08:00
Awuqing
4251eb9e15 优化: 存储类型下拉框分类中文标注 + 去重
问题:API 返回的 rclone 后端纯英文技术名难辨别,且和内置类型存在重复
(如 rclone 的 drive 和内置的 google_drive)。

修复:
- 前端静态定义分类+中文标注(常用/云存储/网盘/文件传输/企业存储/自建存储)
- 排除工具类后端(alias/cache/http/archive 等)和重复后端(drive→用google_drive)
- Select 使用 OptGroup 按分组渲染,搜索仍支持英文/中文关键词
- 常用类型(S3/阿里云/SFTP 等)置顶,其余按分类排列
2026-04-02 13:39:43 +08:00
Wu Qing
94d5fb7286 功能: Docker 一键自动更新 (#32)
功能: Docker 一键自动更新
2026-04-01 23:47:43 +08:00
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
Awuqing
eeec7678a1 优化: 重新设计 Cron 编辑器交互体验
核心问题:预设选中后下方 Tab 编辑器仍展开显示混乱的技术细节。

重新设计为三层交互:
1. 预设按钮(一键选择常见场景,选中高亮,无多余 UI)
2. 自定义选择器(每天/每周/每月/间隔四种模式,直观的时间选择器
   和星期按钮,无需理解 cron 语法)
3. 手动输入(高级用户直接编辑 cron 表达式)

同时优化中文描述为自然语言("每天 02:00 执行" 替代 "02 时 00 分 执行")
2026-04-01 07:44:19 +08:00
Wu Qing
cefbdf3a53 优化: Cron 表达式编辑器增加预设和中文描述 (#26)
优化: Cron 表达式编辑器增加预设和中文描述
2026-04-01 00:17:38 +08:00
Wu Qing
4a56ad05fc 修复: 审计日志补充操作详情 + 版本号注入修复 (#25)
修复: 审计日志补充操作详情 + 版本号注入修复
2026-04-01 00:17:34 +08:00
Wu Qing
9ea02566cb 修复: 存储目标创建/连接测试/类型选择三个关键问题 (#24)
修复: 存储目标创建/连接测试/类型选择三个关键问题
2026-04-01 00:17:29 +08:00
Awuqing
a45b1f7bfb 优化: Cron 表达式编辑器增加预设和中文描述
1. 新增 8 个常用预设按钮(每天 02:00、每 6 小时、每周日、每月 1 日等),
   一键设置无需逐个 Tab 操作
2. 新增中文可读描述(如 "02 时 00 分 执行"),实时显示在表达式下方
3. 选中的预设按钮高亮显示
2026-04-01 00:12:32 +08:00
Awuqing
bfc8728785 修复: 审计日志补充操作详情 + 版本号注入修复
1. 审计日志:所有 handler 的 recordAudit 调用补充有意义的 detail,
   包括创建/更新时记录类型、删除时记录 ID、设置变更时记录修改的 key
2. 版本号:Makefile 的 run/build 都通过 ldflags 注入 git 版本号,
   开发模式不再显示 "dev"
2026-04-01 00:10:51 +08:00
Awuqing
3023a089fb 修复: 存储目标创建/连接测试/类型选择三个关键问题
1. 修复 oneof 白名单仅含 4 种类型,阿里云/腾讯/七牛/FTP/Rclone
   类型的存储目标无法创建(binding 验证直接拒绝)
2. 修复本地磁盘 TestConnection 报 "directory not found",
   在 List 前先 Mkdir 确保目录存在
3. 前端存储类型选项明确标注 Rclone 支持 SFTP/Azure/Dropbox 等
2026-04-01 00:06:08 +08:00
Wu Qing
c437a72aad 功能: 集成 rclone 高级传输特性 + 全 70+ 后端支持 (#23)
功能: 集成 rclone 高级传输特性 + 全 70+ 后端支持
2026-03-31 23:46:02 +08:00
23 changed files with 1059 additions and 778 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

@@ -1,14 +1,15 @@
APP_NAME=backupx
BUILD_DIR=./bin
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
.PHONY: build run test
build:
mkdir -p $(BUILD_DIR)
go build -o $(BUILD_DIR)/$(APP_NAME) ./cmd/backupx
go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o $(BUILD_DIR)/$(APP_NAME) ./cmd/backupx
run:
go run ./cmd/backupx
go run -ldflags "-X main.version=$(VERSION)" ./cmd/backupx
test:
go test ./...

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

@@ -130,7 +130,7 @@ func (h *BackupRecordHandler) Restore(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "", "")
recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "", fmt.Sprintf("恢复备份记录 #%d", id))
response.Success(c, gin.H{"restored": true})
}
@@ -143,10 +143,28 @@ func (h *BackupRecordHandler) Delete(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_record", "delete", "backup_record", fmt.Sprintf("%d", id), "", "")
recordAudit(c, h.auditService, "backup_record", "delete", "backup_record", fmt.Sprintf("%d", id), "", fmt.Sprintf("删除备份记录 #%d", id))
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

@@ -51,7 +51,7 @@ func (h *BackupTaskHandler) Create(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_task", "create", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, "")
recordAudit(c, h.auditService, "backup_task", "create", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, fmt.Sprintf("类型: %s", input.Type))
response.Success(c, item)
}
@@ -70,7 +70,7 @@ func (h *BackupTaskHandler) Update(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_task", "update", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, "")
recordAudit(c, h.auditService, "backup_task", "update", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, fmt.Sprintf("类型: %s, Cron: %s", input.Type, input.CronExpr))
response.Success(c, item)
}
@@ -83,7 +83,7 @@ func (h *BackupTaskHandler) Delete(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_task", "delete", "backup_task", fmt.Sprintf("%d", id), "", "")
recordAudit(c, h.auditService, "backup_task", "delete", "backup_task", fmt.Sprintf("%d", id), "", fmt.Sprintf("删除备份任务 #%d", id))
response.Success(c, gin.H{"deleted": true})
}
@@ -115,6 +115,6 @@ func (h *BackupTaskHandler) Toggle(c *gin.Context) {
if !enabled {
action = "disable"
}
recordAudit(c, h.auditService, "backup_task", action, "backup_task", fmt.Sprintf("%d", id), item.Name, "")
recordAudit(c, h.auditService, "backup_task", action, "backup_task", fmt.Sprintf("%d", id), item.Name, fmt.Sprintf("%s 备份任务", action))
response.Success(c, item)
}

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

@@ -1,6 +1,9 @@
package http
import (
"fmt"
"strings"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
@@ -36,6 +39,10 @@ func (h *SettingsHandler) Update(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "settings", "update", "settings", "", "", "")
keys := make([]string, 0, len(input))
for k := range input {
keys = append(keys, k)
}
recordAudit(c, h.auditService, "settings", "update", "settings", "", "", fmt.Sprintf("修改设置: %s", strings.Join(keys, ", ")))
response.Success(c, settings)
}

View File

@@ -65,7 +65,7 @@ func (h *StorageTargetHandler) Create(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "storage_target", "create", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, "")
recordAudit(c, h.auditService, "storage_target", "create", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, fmt.Sprintf("类型: %s", input.Type))
response.Success(c, item)
}
@@ -84,7 +84,7 @@ func (h *StorageTargetHandler) Update(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "storage_target", "update", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, "")
recordAudit(c, h.auditService, "storage_target", "update", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, fmt.Sprintf("类型: %s", input.Type))
response.Success(c, item)
}
@@ -97,7 +97,7 @@ func (h *StorageTargetHandler) Delete(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "storage_target", "delete", "storage_target", fmt.Sprintf("%d", id), "", "")
recordAudit(c, h.auditService, "storage_target", "delete", "storage_target", fmt.Sprintf("%d", id), "", fmt.Sprintf("删除存储目标 #%d", id))
response.Success(c, gin.H{"deleted": true})
}

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"`
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

@@ -26,8 +26,12 @@ func newProvider(providerType storage.ProviderType, rfs fs.Fs) *Provider {
func (p *Provider) Type() storage.ProviderType { return p.providerType }
// TestConnection 通过列出根目录验证连通性。
// TestConnection 验证连通性。对本地磁盘会先确保目录存在
func (p *Provider) TestConnection(ctx context.Context) error {
// 确保根目录存在(本地磁盘等后端需要预创建)
if err := p.rfs.Mkdir(ctx, ""); err != nil {
return fmt.Errorf("rclone test connection (mkdir): %w", err)
}
_, err := p.rfs.List(ctx, "")
if err != nil {
return fmt.Errorf("rclone test connection: %w", err)

View File

@@ -1,196 +1,327 @@
import { Input, Space, Switch, Tabs, Typography, Radio, Checkbox, Select } from '@arco-design/web-react'
import { useEffect, useState } from 'react'
import { Button, Divider, Input, Select, Space, Switch, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
export interface CronInputProps {
value?: string
onChange?: (value: string) => void
}
const DEFAULT_CRON = '* * * * *'
const DEFAULT_CRON = '0 2 * * *'
type CronPart = 'minute' | 'hour' | 'day' | 'month' | 'week'
interface CronState {
minute: string
hour: string
day: string
month: string
week: string
}
function parseCron(expr: string): CronState {
const parts = (expr || DEFAULT_CRON).trim().split(/\s+/)
return {
minute: parts[0] || '*',
hour: parts[1] || '*',
day: parts[2] || '*',
month: parts[3] || '*',
week: parts[4] || '*',
}
}
function stringifyCron(state: CronState): string {
return `${state.minute} ${state.hour} ${state.day} ${state.month} ${state.week}`
}
function generateOptions(min: number, max: number) {
return Array.from({ length: max - min + 1 }, (_, i) => ({
label: String(i + min),
value: String(i + min),
}))
}
const MINUTES_OPTIONS = generateOptions(0, 59)
const HOURS_OPTIONS = generateOptions(0, 23)
const DAYS_OPTIONS = generateOptions(1, 31)
const MONTHS_OPTIONS = generateOptions(1, 12)
const WEEKS_OPTIONS = [
{ label: '星期日', value: '0' },
{ label: '星期一', value: '1' },
{ label: '星期二', value: '2' },
{ label: '星期三', value: '3' },
{ label: '星期四', value: '4' },
{ label: '星期五', value: '5' },
{ label: '星期六', value: '6' },
// 常用预设
const PRESETS = [
{ label: '每天 02:00', value: '0 2 * * *' },
{ label: '每天 00:00', value: '0 0 * * *' },
{ label: '每 6 小时', value: '0 */6 * * *' },
{ label: '每 12 小时', value: '0 */12 * * *' },
{ label: '每周日 03:00', value: '0 3 * * 0' },
{ label: '每月 1 日 02:00', value: '0 2 1 * *' },
{ label: '每 30 分钟', value: '*/30 * * * *' },
{ label: '每小时整点', value: '0 * * * *' },
]
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => ({
label: `${String(i).padStart(2, '0')}`,
value: String(i),
}))
const MINUTE_OPTIONS = Array.from({ length: 12 }, (_, i) => ({
label: `${String(i * 5).padStart(2, '0')}`,
value: String(i * 5),
}))
const WEEKDAY_OPTIONS = [
{ label: '周一', value: '1' },
{ label: '周二', value: '2' },
{ label: '周三', value: '3' },
{ label: '周四', value: '4' },
{ label: '周五', value: '5' },
{ label: '周六', value: '6' },
{ label: '周日', value: '0' },
]
const DAY_OPTIONS = Array.from({ length: 31 }, (_, i) => ({
label: `${i + 1}`,
value: String(i + 1),
}))
type ScheduleMode = 'daily' | 'weekly' | 'monthly' | 'interval'
// 将 cron 表达式转为自然语言中文描述
function describeCron(expr: string): string {
const parts = expr.trim().split(/\s+/)
if (parts.length !== 5) return ''
const [minute, hour, day, _month, week] = parts
// 每 N 分钟
if (minute.includes('/') && hour === '*' && day === '*' && week === '*') {
return `${minute.split('/')[1]} 分钟执行一次`
}
// 每 N 小时
if (minute !== '*' && hour.includes('/') && day === '*' && week === '*') {
return `${hour.split('/')[1]} 小时执行一次(在第 ${minute} 分)`
}
// 每小时
if (minute !== '*' && hour === '*' && day === '*' && week === '*') {
return `每小时的第 ${minute} 分执行`
}
const hh = hour.padStart(2, '0')
const mm = minute.padStart(2, '0')
const time = `${hh}:${mm}`
// 每周某天
if (day === '*' && week !== '*') {
const weekNames: Record<string, string> = { '0': '日', '1': '一', '2': '二', '3': '三', '4': '四', '5': '五', '6': '六', '7': '日' }
const days = week.split(',').map((w) => `${weekNames[w] || w}`).join('、')
return `${days} ${time} 执行`
}
// 每月某日
if (day !== '*' && week === '*') {
return `每月 ${day}${time} 执行`
}
// 每天
if (day === '*' && week === '*' && hour !== '*' && !hour.includes('/')) {
return `每天 ${time} 执行`
}
return ''
}
export function CronInput({ value, onChange }: CronInputProps) {
const [internalValue, setInternalValue] = useState(value || DEFAULT_CRON)
const [cronExpr, setCronExpr] = useState(value || DEFAULT_CRON)
const [isAdvanced, setIsAdvanced] = useState(false)
const [state, setState] = useState<CronState>(parseCron(internalValue))
const [showCustom, setShowCustom] = useState(false)
// Sync prop to internal state
// 自定义模式的状态
const [mode, setMode] = useState<ScheduleMode>('daily')
const [customHour, setCustomHour] = useState('2')
const [customMinute, setCustomMinute] = useState('0')
const [customWeekdays, setCustomWeekdays] = useState<string[]>(['0'])
const [customDay, setCustomDay] = useState('1')
const [customInterval, setCustomInterval] = useState('6')
// 从 prop 同步
useEffect(() => {
if (value !== undefined && value !== internalValue) {
setInternalValue(value || DEFAULT_CRON)
if (!isAdvanced) {
setState(parseCron(value || DEFAULT_CRON))
}
if (value !== undefined && value !== cronExpr) {
setCronExpr(value || DEFAULT_CRON)
}
}, [value, isAdvanced, internalValue])
}, [value])
const notifyChange = (nextValue: string) => {
setInternalValue(nextValue)
if (onChange) {
onChange(nextValue)
}
const description = useMemo(() => describeCron(cronExpr), [cronExpr])
const isPreset = PRESETS.some((p) => p.value === cronExpr)
const emit = (expr: string) => {
setCronExpr(expr)
onChange?.(expr)
}
const handleStateChange = (part: CronPart, val: string) => {
const nextState = { ...state, [part]: val }
setState(nextState)
notifyChange(stringifyCron(nextState))
}
const renderPartTab = (
part: CronPart,
title: string,
options: { label: string; value: string }[],
allowAnyVal = '*',
// 从自定义选择器构建 cron
const buildCustomCron = (
m: ScheduleMode,
h: string,
min: string,
weekdays: string[],
day: string,
interval: string,
) => {
const currentVal = state[part]
const isAny = currentVal === allowAnyVal || currentVal === '*' || currentVal === '?'
const isSpecific = !isAny && !currentVal.includes('/') && !currentVal.includes('-')
// For simplicity in this visual editor, we only support "every" (*) and "specific values" (1,2,3).
const type = isAny ? 'any' : 'specific'
const specificValues = isSpecific ? currentVal.split(',') : []
switch (m) {
case 'daily':
return `${min} ${h} * * *`
case 'weekly':
return `${min} ${h} * * ${weekdays.sort().join(',') || '0'}`
case 'monthly':
return `${min} ${h} ${day} * *`
case 'interval':
return `0 */${interval} * * *`
default:
return DEFAULT_CRON
}
}
return (
<div style={{ padding: '16px 0' }}>
<Radio.Group
direction="vertical"
value={type}
onChange={(val) => {
if (val === 'any') {
handleStateChange(part, allowAnyVal)
} else {
handleStateChange(part, options[0].value) // Default to first valid item
}
}}
>
<Radio value="any">
<Typography.Text> ({allowAnyVal}) - {title}</Typography.Text>
</Radio>
<Radio value="specific">
<Typography.Text>{title}</Typography.Text>
</Radio>
</Radio.Group>
const handleCustomChange = (updates: {
mode?: ScheduleMode
hour?: string
minute?: string
weekdays?: string[]
day?: string
interval?: string
}) => {
const m = updates.mode ?? mode
const h = updates.hour ?? customHour
const min = updates.minute ?? customMinute
const w = updates.weekdays ?? customWeekdays
const d = updates.day ?? customDay
const iv = updates.interval ?? customInterval
{type === 'specific' && (
<div style={{ paddingLeft: 24, marginTop: 12 }}>
<Select
mode="multiple"
placeholder={`请选择${title}`}
value={specificValues}
options={options}
onChange={(vals: string[]) => {
if (vals.length === 0) {
handleStateChange(part, allowAnyVal)
} else {
// Sort numerically to keep things neat
const sorted = [...vals].sort((a, b) => Number(a) - Number(b))
handleStateChange(part, sorted.join(','))
}
}}
style={{ width: '100%', maxWidth: 400 }}
allowClear
/>
</div>
)}
</div>
)
if (updates.mode !== undefined) setMode(m)
if (updates.hour !== undefined) setCustomHour(h)
if (updates.minute !== undefined) setCustomMinute(min)
if (updates.weekdays !== undefined) setCustomWeekdays(w)
if (updates.day !== undefined) setCustomDay(d)
if (updates.interval !== undefined) setCustomInterval(iv)
emit(buildCustomCron(m, h, min, w, d, iv))
}
return (
<div className="cron-input-container">
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Input
value={internalValue}
onChange={(val) => {
setInternalValue(val)
if (isAdvanced && onChange) {
onChange(val)
}
}}
readOnly={!isAdvanced}
style={{ width: 240, fontFamily: 'monospace' }}
placeholder="* * * * *"
/>
<Space>
<Typography.Text type="secondary"> ()</Typography.Text>
<Switch
checked={isAdvanced}
onChange={(checked) => {
setIsAdvanced(checked)
if (!checked) {
// When switching back to visual, parse the current raw value
setState(parseCron(internalValue))
notifyChange(stringifyCron(parseCron(internalValue)))
}
<div>
{/* 预设按钮 */}
<Space wrap size="small" style={{ marginBottom: 12 }}>
{PRESETS.map((preset) => (
<Button
key={preset.value}
size="small"
type={cronExpr === preset.value ? 'primary' : 'secondary'}
onClick={() => {
emit(preset.value)
setShowCustom(false)
setIsAdvanced(false)
}}
/>
</Space>
>
{preset.label}
</Button>
))}
<Button
size="small"
type={!isPreset && !isAdvanced ? 'primary' : 'secondary'}
onClick={() => {
setShowCustom(true)
setIsAdvanced(false)
}}
>
...
</Button>
</Space>
{/* 中文描述 + cron 表达式 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<Input
value={cronExpr}
readOnly={!isAdvanced}
style={{ width: 180, fontFamily: 'monospace', fontSize: 13 }}
placeholder="0 2 * * *"
onChange={(val) => {
if (isAdvanced) emit(val)
}}
/>
{description && (
<Typography.Text type="secondary">{description}</Typography.Text>
)}
<div style={{ marginLeft: 'auto' }}>
<Space size="mini">
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
<Switch
size="small"
checked={isAdvanced}
onChange={(checked) => {
setIsAdvanced(checked)
setShowCustom(false)
if (!checked) {
setCronExpr(cronExpr)
}
}}
/>
</Space>
</div>
</div>
{!isAdvanced && (
<Tabs type="card-gutter" size="small">
<Tabs.TabPane key="minute" title="分钟">
{renderPartTab('minute', '分钟', MINUTES_OPTIONS, '*')}
</Tabs.TabPane>
<Tabs.TabPane key="hour" title="小时">
{renderPartTab('hour', '小时', HOURS_OPTIONS, '*')}
</Tabs.TabPane>
<Tabs.TabPane key="day" title="日">
{renderPartTab('day', '日', DAYS_OPTIONS, '*')}
</Tabs.TabPane>
<Tabs.TabPane key="month" title="月">
{renderPartTab('month', '月', MONTHS_OPTIONS, '*')}
</Tabs.TabPane>
<Tabs.TabPane key="week" title="周">
{renderPartTab('week', '周', WEEKS_OPTIONS, '*')}
</Tabs.TabPane>
</Tabs>
{/* 自定义选择器 */}
{showCustom && !isAdvanced && (
<div style={{ padding: '12px 16px', background: 'var(--color-fill-1)', borderRadius: 6 }}>
<Space size="large" style={{ marginBottom: 12 }}>
<Button size="small" type={mode === 'daily' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'daily' })}>
</Button>
<Button size="small" type={mode === 'weekly' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'weekly' })}>
</Button>
<Button size="small" type={mode === 'monthly' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'monthly' })}>
</Button>
<Button size="small" type={mode === 'interval' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'interval' })}>
</Button>
</Space>
{mode === 'interval' ? (
<Space align="center">
<Typography.Text></Typography.Text>
<Select
size="small"
value={customInterval}
style={{ width: 80 }}
options={[
{ label: '1', value: '1' },
{ label: '2', value: '2' },
{ label: '3', value: '3' },
{ label: '4', value: '4' },
{ label: '6', value: '6' },
{ label: '8', value: '8' },
{ label: '12', value: '12' },
]}
onChange={(val) => handleCustomChange({ interval: val })}
/>
<Typography.Text></Typography.Text>
</Space>
) : (
<>
{mode === 'weekly' && (
<div style={{ marginBottom: 8 }}>
<Space wrap size="mini">
{WEEKDAY_OPTIONS.map((opt) => (
<Button
key={opt.value}
size="mini"
type={customWeekdays.includes(opt.value) ? 'primary' : 'secondary'}
onClick={() => {
const next = customWeekdays.includes(opt.value)
? customWeekdays.filter((v) => v !== opt.value)
: [...customWeekdays, opt.value]
handleCustomChange({ weekdays: next.length > 0 ? next : [opt.value] })
}}
>
{opt.label}
</Button>
))}
</Space>
</div>
)}
{mode === 'monthly' && (
<div style={{ marginBottom: 8 }}>
<Space align="center">
<Typography.Text></Typography.Text>
<Select
size="small"
value={customDay}
style={{ width: 90 }}
options={DAY_OPTIONS}
onChange={(val) => handleCustomChange({ day: val })}
/>
</Space>
</div>
)}
<Space align="center">
<Typography.Text></Typography.Text>
<Select
size="small"
value={customHour}
style={{ width: 90 }}
options={HOUR_OPTIONS}
onChange={(val) => handleCustomChange({ hour: val })}
/>
<Typography.Text>:</Typography.Text>
<Select
size="small"
value={customMinute}
style={{ width: 90 }}
options={MINUTE_OPTIONS}
onChange={(val) => handleCustomChange({ minute: val })}
/>
</Space>
</>
)}
</div>
)}
</div>
)

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, buildAllTypeOptions } 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,273 +56,158 @@ 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))
// 构建分类的类型选项(去重、中文标注)
const allTypeOptions = useMemo(() => buildAllTypeOptions(rcloneBackends), [rcloneBackends])
// 按分组聚合,用于 Select 的 OptGroup 渲染
const groupedOptions = useMemo(() => {
const groups: Record<string, { label: string; value: string }[]> = {}
for (const opt of allTypeOptions) {
if (!groups[opt.group]) groups[opt.group] = []
groups[opt.group].push({ label: opt.label, value: opt.value })
}
}, [draft.type, rcloneBackends.length, rcloneBackendsLoading])
return groups
}, [allTypeOptions])
const fieldConfigs = useMemo(() => getStorageTargetFieldConfigs(draft.type), [draft.type])
// 当前类型是否为非内置rclone 动态后端)
const isDynamicType = !isBuiltinType(draft.type)
const staticFields = isBuiltinType(draft.type) ? getStorageTargetFieldConfigs(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,
}))
}, [rcloneBackends])
// 当前 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="搜索存储类型..."
filterOption={(input, option) => {
const label = String(option?.props?.children ?? option?.props?.label ?? '')
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)
}}
/>
>
{Object.entries(groupedOptions).map(([group, options]) => (
<Select.OptGroup key={group} label={group}>
{options.map((opt) => (
<Select.Option key={opt.value} value={opt.value}>{opt.label}</Select.Option>
))}
</Select.OptGroup>
))}
</Select>
</div>
<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 +217,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,305 +1,174 @@
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 interface TypeOption {
label: string
value: string
group: string
}
// rclone 后端中不适合做存储目标的(工具类/代理类/只读类)
const EXCLUDED_BACKENDS = new Set([
'alias', 'cache', 'http', 'archive', 'memory', 'tardigrade', // tardigrade = storj 别名
'union', 'crypt', 'chunker', 'compress', 'hasher', 'combine',
'local', // 用内置 local_disk 替代
'drive', // 用内置 google_drive 替代(避免和 rclone 的 drive 重复)
])
// 内置类型(带中文标签的定制化类型,优先展示)
const BUILTIN_OPTIONS: TypeOption[] = [
{ label: '本地磁盘', value: 'local_disk', group: '常用' },
{ label: 'S3 兼容存储AWS / MinIO / 阿里云 / 腾讯云等)', value: 's3', group: '常用' },
{ label: '阿里云 OSS', value: 'aliyun_oss', group: '常用' },
{ label: '腾讯云 COS', value: 'tencent_cos', group: '常用' },
{ label: '七牛云 Kodo', value: 'qiniu_kodo', group: '常用' },
{ label: 'Google Drive', value: 'google_drive', group: '常用' },
{ label: 'WebDAVNextcloud / 坚果云等)', value: 'webdav', group: '常用' },
{ label: 'FTP / FTPS', value: 'ftp', group: '常用' },
]
// rclone 后端的中文标注(仅标注常见的,其余用原始描述)
const RCLONE_LABELS: Record<string, { label: string; group: string }> = {
sftp: { label: 'SFTPSSH 文件传输)', group: '文件传输' },
smb: { label: 'SMB / CIFSWindows 共享)', group: '文件传输' },
azureblob: { label: 'Azure Blob 存储', group: '云存储' },
azurefiles: { label: 'Azure Files 存储', group: '云存储' },
'google cloud storage': { label: 'Google Cloud StorageGCS', group: '云存储' },
b2: { label: 'Backblaze B2', group: '云存储' },
swift: { label: 'OpenStack Swift', group: '云存储' },
dropbox: { label: 'Dropbox', group: '网盘' },
onedrive: { label: 'Microsoft OneDrive', group: '网盘' },
box: { label: 'Box', group: '网盘' },
pcloud: { label: 'pCloud', group: '网盘' },
mega: { label: 'MEGA', group: '网盘' },
'google photos': { label: 'Google Photos', group: '网盘' },
yandex: { label: 'Yandex Disk', group: '网盘' },
pikpak: { label: 'PikPak', group: '网盘' },
iclouddrive: { label: 'iCloud Drive', group: '网盘' },
jottacloud: { label: 'Jottacloud', group: '网盘' },
hidrive: { label: 'HiDrive', group: '网盘' },
protondrive: { label: 'Proton Drive', group: '网盘' },
mailru: { label: 'Mail.ru Cloud', group: '网盘' },
sugarsync: { label: 'SugarSync', group: '网盘' },
putio: { label: 'Put.io', group: '网盘' },
zoho: { label: 'Zoho WorkDrive', group: '网盘' },
internxt: { label: 'Internxt Drive', group: '网盘' },
seafile: { label: 'Seafile', group: '自建存储' },
storj: { label: 'Storj 去中心化存储', group: '云存储' },
hdfs: { label: 'Hadoop HDFS', group: '企业存储' },
oracleobjectstorage: { label: 'Oracle 对象存储', group: '云存储' },
qingstor: { label: '青云 QingStor', group: '云存储' },
sharefile: { label: 'Citrix ShareFile', group: '企业存储' },
filefabric: { label: 'Enterprise File Fabric', group: '企业存储' },
netstorage: { label: 'Akamai NetStorage', group: '企业存储' },
sia: { label: 'Sia 去中心化存储', group: '云存储' },
koofr: { label: 'Koofr / Digi Storage', group: '网盘' },
opendrive: { label: 'OpenDrive', group: '网盘' },
}
/** 构建完整类型选项列表(内置 + rclone去重+分类) */
export function buildAllTypeOptions(rcloneBackends: { name: string; description: string }[]): TypeOption[] {
const result = [...BUILTIN_OPTIONS]
const existingValues = new Set(BUILTIN_OPTIONS.map((o) => o.value))
for (const backend of rcloneBackends) {
if (EXCLUDED_BACKENDS.has(backend.name) || existingValues.has(backend.name)) continue
// 也排除和内置类型实际是同一后端的(如 rclone 的 s3, ftp, webdav 已被内置覆盖)
existingValues.add(backend.name)
const meta = RCLONE_LABELS[backend.name]
result.push({
label: meta?.label ?? `${backend.name}${backend.description}`,
value: backend.name,
group: meta?.group ?? '其他',
})
}
return result
}
export const storageTargetTypeOptions = [
{ label: '本地磁盘', value: 'local_disk' },
{ label: '阿里云 OSS', value: 'aliyun_oss' },
{ label: '腾讯云 COS', value: 'tencent_cos' },
{ label: '七牛云 Kodo', value: 'qiniu_kodo' },
{ label: 'S3 Compatible', value: 's3' },
{ label: 'Google Drive', value: 'google_drive' },
{ label: 'WebDAV', value: 'webdav' },
{ label: 'FTP', value: 'ftp' },
{ label: 'Rclone (70+ 后端)', value: 'rclone' },
] as const
// ---------------------------------------------------------------------------
// 类型标签
// ---------------------------------------------------------------------------
const TYPE_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',
sftp: 'SFTP', smb: 'SMB', azureblob: 'Azure Blob', dropbox: 'Dropbox',
onedrive: 'OneDrive', b2: 'Backblaze B2', mega: 'MEGA', pcloud: 'pCloud',
box: 'Box', swift: 'Swift', pikpak: 'PikPak',
}
export function getStorageTargetTypeLabel(type: StorageTargetType): string {
return TYPE_LABELS[type] || type.toUpperCase()
}

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'