mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-28 04:51:26 +08:00
Compare commits
7 Commits
feat/rclon
...
v1.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cefbdf3a53 | ||
|
|
4a56ad05fc | ||
|
|
9ea02566cb | ||
|
|
a45b1f7bfb | ||
|
|
bfc8728785 | ||
|
|
3023a089fb | ||
|
|
c437a72aad |
@@ -1,14 +1,15 @@
|
|||||||
APP_NAME=backupx
|
APP_NAME=backupx
|
||||||
BUILD_DIR=./bin
|
BUILD_DIR=./bin
|
||||||
|
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
|
|
||||||
.PHONY: build run test
|
.PHONY: build run test
|
||||||
|
|
||||||
build:
|
build:
|
||||||
mkdir -p $(BUILD_DIR)
|
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:
|
run:
|
||||||
go run ./cmd/backupx
|
go run -ldflags "-X main.version=$(VERSION)" ./cmd/backupx
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ func (h *BackupRecordHandler) Restore(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
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})
|
response.Success(c, gin.H{"restored": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ func (h *BackupRecordHandler) Delete(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
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})
|
response.Success(c, gin.H{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func (h *BackupTaskHandler) Create(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
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)
|
response.Success(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ func (h *BackupTaskHandler) Update(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
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)
|
response.Success(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ func (h *BackupTaskHandler) Delete(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
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})
|
response.Success(c, gin.H{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +115,6 @@ func (h *BackupTaskHandler) Toggle(c *gin.Context) {
|
|||||||
if !enabled {
|
if !enabled {
|
||||||
action = "disable"
|
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)
|
response.Success(c, item)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"backupx/server/internal/apperror"
|
"backupx/server/internal/apperror"
|
||||||
"backupx/server/internal/service"
|
"backupx/server/internal/service"
|
||||||
"backupx/server/pkg/response"
|
"backupx/server/pkg/response"
|
||||||
@@ -36,6 +39,10 @@ func (h *SettingsHandler) Update(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
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)
|
response.Success(c, settings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func (h *StorageTargetHandler) Create(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
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)
|
response.Success(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ func (h *StorageTargetHandler) Update(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
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)
|
response.Success(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ func (h *StorageTargetHandler) Delete(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
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})
|
response.Success(c, gin.H{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
type StorageTargetUpsertInput struct {
|
type StorageTargetUpsertInput struct {
|
||||||
Name string `json:"name" binding:"required,min=1,max=128"`
|
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,oneof=local_disk google_drive s3 webdav aliyun_oss tencent_cos qiniu_kodo ftp rclone"`
|
||||||
Description string `json:"description" binding:"max=255"`
|
Description string `json:"description" binding:"max=255"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Config map[string]any `json:"config" binding:"required"`
|
Config map[string]any `json:"config" binding:"required"`
|
||||||
|
|||||||
@@ -26,8 +26,12 @@ func newProvider(providerType storage.ProviderType, rfs fs.Fs) *Provider {
|
|||||||
|
|
||||||
func (p *Provider) Type() storage.ProviderType { return p.providerType }
|
func (p *Provider) Type() storage.ProviderType { return p.providerType }
|
||||||
|
|
||||||
// TestConnection 通过列出根目录验证连通性。
|
// TestConnection 验证连通性。对本地磁盘会先确保目录存在。
|
||||||
func (p *Provider) TestConnection(ctx context.Context) error {
|
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, "")
|
_, err := p.rfs.List(ctx, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("rclone test connection: %w", err)
|
return fmt.Errorf("rclone test connection: %w", err)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Input, Space, Switch, Tabs, Typography, Radio, Checkbox, Select } from '@arco-design/web-react'
|
import { Button, Input, Space, Switch, Tabs, Typography, Radio, Select } from '@arco-design/web-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
export interface CronInputProps {
|
export interface CronInputProps {
|
||||||
value?: string
|
value?: string
|
||||||
@@ -18,6 +18,18 @@ interface CronState {
|
|||||||
week: string
|
week: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 常用预设
|
||||||
|
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 * * * *' },
|
||||||
|
]
|
||||||
|
|
||||||
function parseCron(expr: string): CronState {
|
function parseCron(expr: string): CronState {
|
||||||
const parts = (expr || DEFAULT_CRON).trim().split(/\s+/)
|
const parts = (expr || DEFAULT_CRON).trim().split(/\s+/)
|
||||||
return {
|
return {
|
||||||
@@ -33,6 +45,43 @@ function stringifyCron(state: CronState): string {
|
|||||||
return `${state.minute} ${state.hour} ${state.day} ${state.month} ${state.week}`
|
return `${state.minute} ${state.hour} ${state.day} ${state.month} ${state.week}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将 cron 表达式转为中文可读描述
|
||||||
|
function describeCron(expr: string): string {
|
||||||
|
const parts = expr.trim().split(/\s+/)
|
||||||
|
if (parts.length !== 5) return ''
|
||||||
|
const [minute, hour, day, month, week] = parts
|
||||||
|
|
||||||
|
const segments: string[] = []
|
||||||
|
|
||||||
|
// 月
|
||||||
|
if (month !== '*') segments.push(`${month} 月`)
|
||||||
|
// 日
|
||||||
|
if (day !== '*') segments.push(`${day} 日`)
|
||||||
|
// 周
|
||||||
|
if (week !== '*') {
|
||||||
|
const weekNames: Record<string, string> = { '0': '日', '1': '一', '2': '二', '3': '三', '4': '四', '5': '五', '6': '六', '7': '日' }
|
||||||
|
const weekDesc = week.split(',').map((w) => weekNames[w] || w).join('、')
|
||||||
|
segments.push(`星期${weekDesc}`)
|
||||||
|
}
|
||||||
|
// 小时
|
||||||
|
if (hour.includes('/')) {
|
||||||
|
segments.push(`每 ${hour.split('/')[1]} 小时`)
|
||||||
|
} else if (hour !== '*') {
|
||||||
|
segments.push(`${hour.padStart(2, '0')} 时`)
|
||||||
|
}
|
||||||
|
// 分钟
|
||||||
|
if (minute.includes('/')) {
|
||||||
|
segments.push(`每 ${minute.split('/')[1]} 分钟`)
|
||||||
|
} else if (minute !== '*') {
|
||||||
|
segments.push(`${minute.padStart(2, '0')} 分`)
|
||||||
|
} else if (hour !== '*' && !hour.includes('/')) {
|
||||||
|
segments.push('00 分')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length === 0) return '每分钟执行'
|
||||||
|
return segments.join(' ') + ' 执行'
|
||||||
|
}
|
||||||
|
|
||||||
function generateOptions(min: number, max: number) {
|
function generateOptions(min: number, max: number) {
|
||||||
return Array.from({ length: max - min + 1 }, (_, i) => ({
|
return Array.from({ length: max - min + 1 }, (_, i) => ({
|
||||||
label: String(i + min),
|
label: String(i + min),
|
||||||
@@ -69,6 +118,8 @@ export function CronInput({ value, onChange }: CronInputProps) {
|
|||||||
}
|
}
|
||||||
}, [value, isAdvanced, internalValue])
|
}, [value, isAdvanced, internalValue])
|
||||||
|
|
||||||
|
const description = useMemo(() => describeCron(internalValue), [internalValue])
|
||||||
|
|
||||||
const notifyChange = (nextValue: string) => {
|
const notifyChange = (nextValue: string) => {
|
||||||
setInternalValue(nextValue)
|
setInternalValue(nextValue)
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
@@ -82,6 +133,12 @@ export function CronInput({ value, onChange }: CronInputProps) {
|
|||||||
notifyChange(stringifyCron(nextState))
|
notifyChange(stringifyCron(nextState))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePreset = (cronExpr: string) => {
|
||||||
|
setInternalValue(cronExpr)
|
||||||
|
setState(parseCron(cronExpr))
|
||||||
|
if (onChange) onChange(cronExpr)
|
||||||
|
}
|
||||||
|
|
||||||
const renderPartTab = (
|
const renderPartTab = (
|
||||||
part: CronPart,
|
part: CronPart,
|
||||||
title: string,
|
title: string,
|
||||||
@@ -91,8 +148,7 @@ export function CronInput({ value, onChange }: CronInputProps) {
|
|||||||
const currentVal = state[part]
|
const currentVal = state[part]
|
||||||
const isAny = currentVal === allowAnyVal || currentVal === '*' || currentVal === '?'
|
const isAny = currentVal === allowAnyVal || currentVal === '*' || currentVal === '?'
|
||||||
const isSpecific = !isAny && !currentVal.includes('/') && !currentVal.includes('-')
|
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 type = isAny ? 'any' : 'specific'
|
||||||
const specificValues = isSpecific ? currentVal.split(',') : []
|
const specificValues = isSpecific ? currentVal.split(',') : []
|
||||||
|
|
||||||
@@ -105,7 +161,7 @@ export function CronInput({ value, onChange }: CronInputProps) {
|
|||||||
if (val === 'any') {
|
if (val === 'any') {
|
||||||
handleStateChange(part, allowAnyVal)
|
handleStateChange(part, allowAnyVal)
|
||||||
} else {
|
} else {
|
||||||
handleStateChange(part, options[0].value) // Default to first valid item
|
handleStateChange(part, options[0].value)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -128,7 +184,6 @@ export function CronInput({ value, onChange }: CronInputProps) {
|
|||||||
if (vals.length === 0) {
|
if (vals.length === 0) {
|
||||||
handleStateChange(part, allowAnyVal)
|
handleStateChange(part, allowAnyVal)
|
||||||
} else {
|
} else {
|
||||||
// Sort numerically to keep things neat
|
|
||||||
const sorted = [...vals].sort((a, b) => Number(a) - Number(b))
|
const sorted = [...vals].sort((a, b) => Number(a) - Number(b))
|
||||||
handleStateChange(part, sorted.join(','))
|
handleStateChange(part, sorted.join(','))
|
||||||
}
|
}
|
||||||
@@ -144,6 +199,24 @@ export function CronInput({ value, onChange }: CronInputProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="cron-input-container">
|
<div className="cron-input-container">
|
||||||
|
{/* 常用预设 */}
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>常用预设</Typography.Text>
|
||||||
|
<Space wrap size="small">
|
||||||
|
{PRESETS.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.value}
|
||||||
|
size="small"
|
||||||
|
type={internalValue === preset.value ? 'primary' : 'secondary'}
|
||||||
|
onClick={() => handlePreset(preset.value)}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表达式 + 可读描述 */}
|
||||||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Input
|
<Input
|
||||||
value={internalValue}
|
value={internalValue}
|
||||||
@@ -158,13 +231,12 @@ export function CronInput({ value, onChange }: CronInputProps) {
|
|||||||
placeholder="* * * * *"
|
placeholder="* * * * *"
|
||||||
/>
|
/>
|
||||||
<Space>
|
<Space>
|
||||||
<Typography.Text type="secondary">高级模式 (手动输入)</Typography.Text>
|
<Typography.Text type="secondary">高级模式</Typography.Text>
|
||||||
<Switch
|
<Switch
|
||||||
checked={isAdvanced}
|
checked={isAdvanced}
|
||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
setIsAdvanced(checked)
|
setIsAdvanced(checked)
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
// When switching back to visual, parse the current raw value
|
|
||||||
setState(parseCron(internalValue))
|
setState(parseCron(internalValue))
|
||||||
notifyChange(stringifyCron(parseCron(internalValue)))
|
notifyChange(stringifyCron(parseCron(internalValue)))
|
||||||
}
|
}
|
||||||
@@ -173,6 +245,13 @@ export function CronInput({ value, onChange }: CronInputProps) {
|
|||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 中文可读描述 */}
|
||||||
|
{description && (
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12, marginTop: 0 }}>
|
||||||
|
{description}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isAdvanced && (
|
{!isAdvanced && (
|
||||||
<Tabs type="card-gutter" size="small">
|
<Tabs type="card-gutter" size="small">
|
||||||
<Tabs.TabPane key="minute" title="分钟">
|
<Tabs.TabPane key="minute" title="分钟">
|
||||||
|
|||||||
@@ -301,5 +301,5 @@ export const storageTargetTypeOptions = [
|
|||||||
{ label: 'Google Drive', value: 'google_drive' },
|
{ label: 'Google Drive', value: 'google_drive' },
|
||||||
{ label: 'WebDAV', value: 'webdav' },
|
{ label: 'WebDAV', value: 'webdav' },
|
||||||
{ label: 'FTP', value: 'ftp' },
|
{ label: 'FTP', value: 'ftp' },
|
||||||
{ label: 'Rclone (70+ 后端)', value: 'rclone' },
|
{ label: 'Rclone — SFTP / Azure / Dropbox / OneDrive 等 70+ 后端', value: 'rclone' },
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
Reference in New Issue
Block a user