mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-07 06:03:00 +08:00
功能: 集成 rclone 高级传输特性 + 全 70+ 后端支持
1. 失败自动重试:rclone Pacer 指数退避,默认 10 次底层 HTTP 重试 2. 带宽限制:配置 bandwidth_limit + Settings 运行时可调 3. 上传实时进度:progressReader + LogHub SSE 推送字节级进度/速率 4. 存储空间查询:StorageAbout 可选接口,GetUsage 返回远端真实空间 5. 全 rclone 后端:backend/all 引入 70+ 后端,新增 rclone 存储类型, API 驱动的可搜索后端选择器 + 动态配置表单
This commit is contained in:
@@ -2,6 +2,7 @@ import { Alert, Button, Divider, Drawer, Input, Select, Space, Switch, Typograph
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { getStorageTargetFieldConfigs, getStorageTargetTypeLabel, storageTargetTypeOptions } from './field-config'
|
||||
import type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetType } from '../../types/storage-targets'
|
||||
import { listRcloneBackends, type RcloneBackendInfo } from '../../services/rclone'
|
||||
|
||||
interface StorageTargetFormDrawerProps {
|
||||
visible: boolean
|
||||
@@ -38,6 +39,10 @@ export function StorageTargetFormDrawer({
|
||||
const [error, setError] = useState('')
|
||||
const [testResult, setTestResult] = useState<StorageConnectionTestResult | null>(null)
|
||||
|
||||
// rclone 后端列表(API 驱动)
|
||||
const [rcloneBackends, setRcloneBackends] = useState<RcloneBackendInfo[]>([])
|
||||
const [rcloneBackendsLoading, setRcloneBackendsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return
|
||||
@@ -59,8 +64,35 @@ export function StorageTargetFormDrawer({
|
||||
setTestResult(null)
|
||||
}, [initialValue, visible])
|
||||
|
||||
// 当类型切换到 rclone 时,加载后端列表
|
||||
useEffect(() => {
|
||||
if (draft.type === 'rclone' && rcloneBackends.length === 0 && !rcloneBackendsLoading) {
|
||||
setRcloneBackendsLoading(true)
|
||||
listRcloneBackends()
|
||||
.then(setRcloneBackends)
|
||||
.catch(() => {})
|
||||
.finally(() => setRcloneBackendsLoading(false))
|
||||
}
|
||||
}, [draft.type, rcloneBackends.length, rcloneBackendsLoading])
|
||||
|
||||
const fieldConfigs = useMemo(() => getStorageTargetFieldConfigs(draft.type), [draft.type])
|
||||
|
||||
// 当前选中的 rclone 后端信息
|
||||
const selectedRcloneBackend = useMemo(() => {
|
||||
if (draft.type !== 'rclone') return null
|
||||
const backendName = draft.config.backend as string
|
||||
if (!backendName) return null
|
||||
return rcloneBackends.find((b) => b.name === backendName) || null
|
||||
}, [draft.type, draft.config.backend, rcloneBackends])
|
||||
|
||||
// rclone 后端下拉选项
|
||||
const rcloneBackendOptions = useMemo(() => {
|
||||
return rcloneBackends.map((b) => ({
|
||||
label: `${b.name} — ${b.description}`,
|
||||
value: b.name,
|
||||
}))
|
||||
}, [rcloneBackends])
|
||||
|
||||
function updateConfig(key: string, value: string | boolean) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
@@ -75,6 +107,13 @@ export function StorageTargetFormDrawer({
|
||||
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
|
||||
@@ -121,6 +160,131 @@ export function StorageTargetFormDrawer({
|
||||
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 }}>
|
||||
支持 SFTP、Azure Blob、Dropbox、OneDrive、B2、SMB 等 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) => {
|
||||
const value = draft.config[field.key]
|
||||
const normalizedValue = 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>
|
||||
{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}
|
||||
</Space>
|
||||
) : field.type === 'password' ? (
|
||||
<Input.Password
|
||||
value={String(normalizedValue)}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(nextValue) => updateConfig(field.key, nextValue)}
|
||||
/>
|
||||
) : (
|
||||
<Input value={String(normalizedValue)} placeholder={field.placeholder} onChange={(nextValue) => updateConfig(field.key, nextValue)} />
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width={560}
|
||||
@@ -176,43 +340,7 @@ export function StorageTargetFormDrawer({
|
||||
{getStorageTargetTypeLabel(draft.type)}
|
||||
</Typography.Title>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{fieldConfigs.map((field) => {
|
||||
const value = draft.config[field.key]
|
||||
const normalizedValue = 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>
|
||||
{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}
|
||||
</Space>
|
||||
) : field.type === 'password' ? (
|
||||
<Input.Password
|
||||
value={String(normalizedValue)}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(nextValue) => updateConfig(field.key, nextValue)}
|
||||
/>
|
||||
) : (
|
||||
<Input value={String(normalizedValue)} placeholder={field.placeholder} onChange={(nextValue) => updateConfig(field.key, nextValue)} />
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
{draft.type === 'rclone' ? renderRcloneFields() : renderStaticFields()}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -216,6 +216,7 @@ const FIELD_CONFIG_MAP: Record<StorageTargetType, StorageTargetFieldConfig[]> =
|
||||
placeholder: '输入新的 SecretKey',
|
||||
},
|
||||
],
|
||||
rclone: [], // 动态表单,字段从 API 获取(见 StorageTargetFormDrawer)
|
||||
ftp: [
|
||||
{
|
||||
key: 'host',
|
||||
@@ -284,6 +285,8 @@ export function getStorageTargetTypeLabel(type: StorageTargetType) {
|
||||
return '七牛云 Kodo'
|
||||
case 'ftp':
|
||||
return 'FTP'
|
||||
case 'rclone':
|
||||
return 'Rclone (70+ 后端)'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
@@ -298,4 +301,5 @@ export const storageTargetTypeOptions = [
|
||||
{ label: 'Google Drive', value: 'google_drive' },
|
||||
{ label: 'WebDAV', value: 'webdav' },
|
||||
{ label: 'FTP', value: 'ftp' },
|
||||
{ label: 'Rclone (70+ 后端)', value: 'rclone' },
|
||||
] as const
|
||||
|
||||
19
web/src/services/rclone.ts
Normal file
19
web/src/services/rclone.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { http } from './http'
|
||||
|
||||
export interface RcloneBackendOption {
|
||||
key: string
|
||||
label: string
|
||||
required: boolean
|
||||
isPassword: boolean
|
||||
}
|
||||
|
||||
export interface RcloneBackendInfo {
|
||||
name: string
|
||||
description: string
|
||||
options: RcloneBackendOption[]
|
||||
}
|
||||
|
||||
export async function listRcloneBackends(): Promise<RcloneBackendInfo[]> {
|
||||
const { data } = await http.get<{ data: RcloneBackendInfo[] }>('/api/storage-targets/rclone/backends')
|
||||
return data.data
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type StorageTargetType = 'local_disk' | 'google_drive' | 's3' | 'webdav' | 'aliyun_oss' | 'tencent_cos' | 'qiniu_kodo' | 'ftp'
|
||||
export type StorageTargetType = 'local_disk' | 'google_drive' | 's3' | 'webdav' | 'aliyun_oss' | 'tencent_cos' | 'qiniu_kodo' | 'ftp' | 'rclone'
|
||||
export type StorageTestStatus = 'unknown' | 'success' | 'failed'
|
||||
export type StorageFieldType = 'input' | 'password' | 'switch'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user