diff --git a/server/internal/app/app.go b/server/internal/app/app.go index 99ddee3..bb3174d 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -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) diff --git a/server/internal/service/storage_target_service.go b/server/internal/service/storage_target_service.go index 2e9ec4c..0896e79 100644 --- a/server/internal/service/storage_target_service.go +++ b/server/internal/service/storage_target_service.go @@ -21,7 +21,7 @@ import ( type StorageTargetUpsertInput struct { Name string `json:"name" binding:"required,min=1,max=128"` - Type string `json:"type" binding:"required,oneof=local_disk google_drive s3 webdav aliyun_oss tencent_cos qiniu_kodo ftp rclone"` + Type string `json:"type" binding:"required,min=1"` Description string `json:"description" binding:"max=255"` Enabled bool `json:"enabled"` Config map[string]any `json:"config" binding:"required"` diff --git a/server/internal/storage/rclone/factory.go b/server/internal/storage/rclone/factory.go index 358e38d..bf342df 100644 --- a/server/internal/storage/rclone/factory.go +++ b/server/internal/storage/rclone/factory.go @@ -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)) + } +} diff --git a/web/src/components/storage-targets/StorageTargetFormDrawer.tsx b/web/src/components/storage-targets/StorageTargetFormDrawer.tsx index f7b1e8c..9fb65ad 100644 --- a/web/src/components/storage-targets/StorageTargetFormDrawer.tsx +++ b/web/src/components/storage-targets/StorageTargetFormDrawer.tsx @@ -1,6 +1,6 @@ import { Alert, Button, Divider, Drawer, Input, Select, Space, Switch, Typography } from '@arco-design/web-react' import { useEffect, useMemo, useState } from 'react' -import { getStorageTargetFieldConfigs, getStorageTargetTypeLabel, storageTargetTypeOptions } from './field-config' +import { getStorageTargetFieldConfigs, getStorageTargetTypeLabel, isBuiltinType, builtinTypeOptions } from './field-config' import type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetType } from '../../types/storage-targets' import { listRcloneBackends, type RcloneBackendInfo } from '../../services/rclone' @@ -16,37 +16,29 @@ interface StorageTargetFormDrawerProps { } function createEmptyDraft(type: StorageTargetType = 'local_disk'): StorageTargetPayload { - return { - name: '', - type, - description: '', - enabled: true, - config: {}, - } + return { name: '', type, description: '', enabled: true, config: {} } } export function StorageTargetFormDrawer({ - visible, - loading, - testing, - initialValue, - onCancel, - onSubmit, - onTest, - onGoogleDriveAuth, + visible, loading, testing, initialValue, onCancel, onSubmit, onTest, onGoogleDriveAuth, }: StorageTargetFormDrawerProps) { const [draft, setDraft] = useState(createEmptyDraft()) const [error, setError] = useState('') const [testResult, setTestResult] = useState(null) - - // rclone 后端列表(API 驱动) const [rcloneBackends, setRcloneBackends] = useState([]) - const [rcloneBackendsLoading, setRcloneBackendsLoading] = useState(false) + const [backendsLoaded, setBackendsLoaded] = useState(false) + // 加载 rclone 后端列表 useEffect(() => { - if (!visible) { - return + if (visible && !backendsLoaded) { + listRcloneBackends() + .then((data) => { setRcloneBackends(data); setBackendsLoaded(true) }) + .catch(() => setBackendsLoaded(true)) } + }, [visible, backendsLoaded]) + + useEffect(() => { + if (!visible) return if (!initialValue) { setDraft(createEmptyDraft()) setError('') @@ -64,256 +56,137 @@ export function StorageTargetFormDrawer({ setTestResult(null) }, [initialValue, visible]) - // 当类型切换到 rclone 时,加载后端列表 - useEffect(() => { - if (draft.type === 'rclone' && rcloneBackends.length === 0 && !rcloneBackendsLoading) { - setRcloneBackendsLoading(true) - listRcloneBackends() - .then(setRcloneBackends) - .catch(() => {}) - .finally(() => setRcloneBackendsLoading(false)) - } - }, [draft.type, rcloneBackends.length, rcloneBackendsLoading]) - - const fieldConfigs = useMemo(() => getStorageTargetFieldConfigs(draft.type), [draft.type]) + // 合并类型选项:内置 + 全部 rclone 后端 + const allTypeOptions = useMemo(() => { + const builtinValues = new Set(builtinTypeOptions.map((o) => o.value)) + const rcloneOptions = rcloneBackends + .filter((b) => !builtinValues.has(b.name) && b.name !== 'local' && b.name !== 'rclone') + .map((b) => ({ label: `${b.name.toUpperCase()} — ${b.description}`, value: b.name })) + return [ + ...builtinTypeOptions.map((o) => ({ ...o, label: o.label, value: o.value as string })), + ...rcloneOptions, + ] + }, [rcloneBackends]) - // 当前选中的 rclone 后端信息 - const 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 isDynamicType = !isBuiltinType(draft.type) + const staticFields = isBuiltinType(draft.type) ? getStorageTargetFieldConfigs(draft.type) : [] - // 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) - } - - // 渲染 rclone 类型的动态配置表单 - function renderRcloneFields() { - return ( - <> -
- Rclone 后端类型 * - updateConfig('root', value)} - /> - - 远端存储的根路径、桶名或挂载点,留空使用根目录 - -
- - {selectedRcloneBackend && selectedRcloneBackend.options.length > 0 && ( - <> - - {selectedRcloneBackend.name} 配置 - - {selectedRcloneBackend.options.map((opt) => ( -
- - {opt.key} - {opt.required ? ' *' : ''} - - {opt.isPassword ? ( - updateConfig(opt.key, value)} - /> - ) : ( - updateConfig(opt.key, value)} - /> - )} - {opt.label && ( - - {opt.label} - - )} -
- ))} - - )} - - ) + const e = validate(draft); if (e) { setError(e); return } + setError(''); await onGoogleDriveAuth(draft, initialValue?.id) } - // 渲染常规类型的静态字段 + // 渲染静态字段(内置类型) 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 (
- - {field.label} - {field.required ? ' *' : ''} - + {field.label}{field.required ? ' *' : ''} {field.type === 'switch' ? ( - updateConfig(field.key, checked)} /> - {field.description ? {field.description} : null} + updateConfig(field.key, v)} /> + {field.description && {field.description}} ) : field.type === 'password' ? ( - updateConfig(field.key, nextValue)} - /> + updateConfig(field.key, v)} /> ) : ( - updateConfig(field.key, nextValue)} /> + updateConfig(field.key, v)} /> + )} + {field.description && field.type !== 'switch' && ( + {field.description} + )} + {initialValue?.maskedFields?.includes(field.key) && !draft.config[field.key] && ( + 已存在敏感配置,留空则保持不变。 )} - {field.description && field.type !== 'switch' ? ( - - {field.description} - - ) : null} - {initialValue?.maskedFields?.includes(field.key) && !draft.config[field.key] ? ( - - 已存在敏感配置,留空则保持不变。 - - ) : null}
) }) } + // 渲染动态字段(rclone 后端) + function renderDynamicFields() { + return ( + <> +
+ 远端路径 + updateConfig('root', v)} /> + 远端根路径、桶名或挂载点,留空使用根目录 +
+ {dynamicBackend && dynamicBackend.options.length > 0 && dynamicBackend.options.map((opt) => ( +
+ {opt.key}{opt.required ? ' *' : ''} + {opt.isPassword ? ( + updateConfig(opt.key, v)} /> + ) : ( + updateConfig(opt.key, v)} /> + )} + {opt.label && ( + {opt.label} + )} +
+ ))} + + ) + } + return ( - + {error ? : } - {testResult ? : null} + {testResult && }
名称 - setDraft((current) => ({ ...current, name: value }))} /> + setDraft((c) => ({ ...c, name: v }))} />
- 类型 + 存储类型