Compare commits

...

7 Commits

Author SHA1 Message Date
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
13 changed files with 486 additions and 84 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

@@ -94,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)
@@ -102,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

@@ -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

@@ -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, isBuiltinType, builtinTypeOptions } 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'
@@ -56,17 +56,18 @@ export function StorageTargetFormDrawer({
setTestResult(null)
}, [initialValue, visible])
// 合并类型选项:内置 + 全部 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])
// 构建分类的类型选项(去重、中文标注)
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 })
}
return groups
}, [allTypeOptions])
// 当前类型是否为非内置rclone 动态后端)
const isDynamicType = !isBuiltinType(draft.type)
@@ -179,17 +180,24 @@ export function StorageTargetFormDrawer({
<Select
showSearch
value={draft.type || undefined}
placeholder="搜索存储类型(如 SFTP、Azure Blob、Dropbox..."
options={allTypeOptions}
placeholder="搜索存储类型..."
filterOption={(input, option) => {
const label = (option?.props?.children ?? option?.props?.label ?? '') as string
const label = String(option?.props?.children ?? option?.props?.label ?? '')
return label.toLowerCase().includes(input.toLowerCase())
}}
onChange={(value) => {
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>

View File

@@ -1,6 +1,9 @@
import type { StorageTargetFieldConfig, StorageTargetType } from '../../types/storage-targets'
// 内置类型的静态字段配置(定制化配置结构)
// ---------------------------------------------------------------------------
// 内置类型的静态字段配置
// ---------------------------------------------------------------------------
const BUILTIN_FIELD_CONFIG: Record<string, StorageTargetFieldConfig[]> = {
local_disk: [
{ key: 'basePath', label: '基础目录', type: 'input', required: true, placeholder: '/data/backups', description: 'BackupX 将在该目录下创建和管理备份文件。' },
@@ -55,34 +58,117 @@ const BUILTIN_FIELD_CONFIG: Record<string, StorageTargetFieldConfig[]> = {
const BUILTIN_TYPES = new Set(Object.keys(BUILTIN_FIELD_CONFIG))
/** 是否为内置类型 */
export function isBuiltinType(type: StorageTargetType): boolean {
return BUILTIN_TYPES.has(type)
}
/** 获取静态字段配置 */
export function getStorageTargetFieldConfigs(type: StorageTargetType): StorageTargetFieldConfig[] {
return BUILTIN_FIELD_CONFIG[type] ?? []
}
const BUILTIN_LABELS: Record<string, string> = {
// ---------------------------------------------------------------------------
// 存储类型完整列表(分类、中文标注、去重)
// ---------------------------------------------------------------------------
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
}
// ---------------------------------------------------------------------------
// 类型标签
// ---------------------------------------------------------------------------
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', rclone: 'Rclone',
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 BUILTIN_LABELS[type] || type.toUpperCase()
return TYPE_LABELS[type] || type.toUpperCase()
}
/** 内置类型选项(下拉框"常用"分组) */
export const builtinTypeOptions = [
{ 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' },
]

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