功能: 集成 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:
Awuqing
2026-03-31 23:37:59 +08:00
parent cf5740b573
commit 1003302bdd
23 changed files with 1220 additions and 74 deletions

View File

@@ -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 }}>
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) => {
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>

View File

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

View 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
}

View File

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