mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-06 02:09:37 +08:00
@@ -73,6 +73,8 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
|||||||
storageRclone.NewFTPFactory(),
|
storageRclone.NewFTPFactory(),
|
||||||
storageRclone.NewRcloneFactory(),
|
storageRclone.NewRcloneFactory(),
|
||||||
)
|
)
|
||||||
|
// 将全部 rclone 后端注册为独立存储类型(sftp、azureblob、dropbox 等与 s3、ftp 完全平级)
|
||||||
|
storageRclone.RegisterAllBackends(storageRegistry)
|
||||||
storageTargetService := service.NewStorageTargetService(storageTargetRepo, oauthSessionRepo, storageRegistry, configCipher)
|
storageTargetService := service.NewStorageTargetService(storageTargetRepo, oauthSessionRepo, storageRegistry, configCipher)
|
||||||
storageTargetService.SetBackupTaskRepository(backupTaskRepo)
|
storageTargetService.SetBackupTaskRepository(backupTaskRepo)
|
||||||
storageTargetService.SetBackupRecordRepository(backupRecordRepo)
|
storageTargetService.SetBackupRecordRepository(backupRecordRepo)
|
||||||
|
|||||||
@@ -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 aliyun_oss tencent_cos qiniu_kodo ftp rclone"`
|
Type string `json:"type" binding:"required,min=1"`
|
||||||
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"`
|
||||||
|
|||||||
@@ -434,3 +434,75 @@ type BackendOption struct {
|
|||||||
Required bool `json:"required"`
|
Required bool `json:"required"`
|
||||||
IsPassword bool `json:"isPassword"`
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Alert, Button, Divider, Drawer, Input, Select, Space, Switch, Typography } from '@arco-design/web-react'
|
import { Alert, Button, Divider, Drawer, Input, Select, Space, Switch, Typography } from '@arco-design/web-react'
|
||||||
import { useEffect, useMemo, useState } from '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 type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetType } from '../../types/storage-targets'
|
||||||
import { listRcloneBackends, type RcloneBackendInfo } from '../../services/rclone'
|
import { listRcloneBackends, type RcloneBackendInfo } from '../../services/rclone'
|
||||||
|
|
||||||
@@ -16,37 +16,29 @@ interface StorageTargetFormDrawerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createEmptyDraft(type: StorageTargetType = 'local_disk'): StorageTargetPayload {
|
function createEmptyDraft(type: StorageTargetType = 'local_disk'): StorageTargetPayload {
|
||||||
return {
|
return { name: '', type, description: '', enabled: true, config: {} }
|
||||||
name: '',
|
|
||||||
type,
|
|
||||||
description: '',
|
|
||||||
enabled: true,
|
|
||||||
config: {},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StorageTargetFormDrawer({
|
export function StorageTargetFormDrawer({
|
||||||
visible,
|
visible, loading, testing, initialValue, onCancel, onSubmit, onTest, onGoogleDriveAuth,
|
||||||
loading,
|
|
||||||
testing,
|
|
||||||
initialValue,
|
|
||||||
onCancel,
|
|
||||||
onSubmit,
|
|
||||||
onTest,
|
|
||||||
onGoogleDriveAuth,
|
|
||||||
}: StorageTargetFormDrawerProps) {
|
}: StorageTargetFormDrawerProps) {
|
||||||
const [draft, setDraft] = useState<StorageTargetPayload>(createEmptyDraft())
|
const [draft, setDraft] = useState<StorageTargetPayload>(createEmptyDraft())
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [testResult, setTestResult] = useState<StorageConnectionTestResult | null>(null)
|
const [testResult, setTestResult] = useState<StorageConnectionTestResult | null>(null)
|
||||||
|
|
||||||
// rclone 后端列表(API 驱动)
|
|
||||||
const [rcloneBackends, setRcloneBackends] = useState<RcloneBackendInfo[]>([])
|
const [rcloneBackends, setRcloneBackends] = useState<RcloneBackendInfo[]>([])
|
||||||
const [rcloneBackendsLoading, setRcloneBackendsLoading] = useState(false)
|
const [backendsLoaded, setBackendsLoaded] = useState(false)
|
||||||
|
|
||||||
|
// 加载 rclone 后端列表
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && !backendsLoaded) {
|
||||||
|
listRcloneBackends()
|
||||||
|
.then((data) => { setRcloneBackends(data); setBackendsLoaded(true) })
|
||||||
|
.catch(() => setBackendsLoaded(true))
|
||||||
|
}
|
||||||
|
}, [visible, backendsLoaded])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible) return
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!initialValue) {
|
if (!initialValue) {
|
||||||
setDraft(createEmptyDraft())
|
setDraft(createEmptyDraft())
|
||||||
setError('')
|
setError('')
|
||||||
@@ -64,256 +56,137 @@ export function StorageTargetFormDrawer({
|
|||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
}, [initialValue, visible])
|
}, [initialValue, visible])
|
||||||
|
|
||||||
// 当类型切换到 rclone 时,加载后端列表
|
// 合并类型选项:内置 + 全部 rclone 后端
|
||||||
useEffect(() => {
|
const allTypeOptions = useMemo(() => {
|
||||||
if (draft.type === 'rclone' && rcloneBackends.length === 0 && !rcloneBackendsLoading) {
|
const builtinValues = new Set(builtinTypeOptions.map((o) => o.value))
|
||||||
setRcloneBackendsLoading(true)
|
const rcloneOptions = rcloneBackends
|
||||||
listRcloneBackends()
|
.filter((b) => !builtinValues.has(b.name) && b.name !== 'local' && b.name !== 'rclone')
|
||||||
.then(setRcloneBackends)
|
.map((b) => ({ label: `${b.name.toUpperCase()} — ${b.description}`, value: b.name }))
|
||||||
.catch(() => {})
|
return [
|
||||||
.finally(() => setRcloneBackendsLoading(false))
|
...builtinTypeOptions.map((o) => ({ ...o, label: o.label, value: o.value as string })),
|
||||||
}
|
...rcloneOptions,
|
||||||
}, [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])
|
}, [rcloneBackends])
|
||||||
|
|
||||||
|
// 当前类型是否为非内置(rclone 动态后端)
|
||||||
|
const isDynamicType = !isBuiltinType(draft.type)
|
||||||
|
const staticFields = isBuiltinType(draft.type) ? getStorageTargetFieldConfigs(draft.type) : []
|
||||||
|
|
||||||
|
// 当前 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) {
|
function updateConfig(key: string, value: string | boolean) {
|
||||||
setDraft((current) => ({
|
setDraft((c) => ({ ...c, config: { ...c.config, [key]: value } }))
|
||||||
...current,
|
|
||||||
config: {
|
|
||||||
...current.config,
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validate(value: StorageTargetPayload) {
|
function validate(value: StorageTargetPayload) {
|
||||||
if (!value.name.trim()) {
|
if (!value.name.trim()) return '请输入存储目标名称'
|
||||||
return '请输入存储目标名称'
|
if (!value.type.trim()) return '请选择存储类型'
|
||||||
}
|
if (isBuiltinType(value.type)) {
|
||||||
// rclone 类型需要选择后端
|
for (const field of staticFields) {
|
||||||
if (value.type === 'rclone') {
|
if (!field.required || field.type === 'switch') continue
|
||||||
if (!value.config.backend || !(value.config.backend as string).trim()) {
|
const v = value.config[field.key]
|
||||||
return '请选择 Rclone 后端类型'
|
if (typeof v !== 'string' || !v.trim()) return `请填写${field.label}`
|
||||||
}
|
|
||||||
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}`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const validationError = validate(draft)
|
const e = validate(draft); if (e) { setError(e); return }
|
||||||
if (validationError) {
|
setError(''); await onSubmit(draft, initialValue?.id)
|
||||||
setError(validationError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setError('')
|
|
||||||
await onSubmit(draft, initialValue?.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
const validationError = validate(draft)
|
const e = validate(draft); if (e) { setError(e); return }
|
||||||
if (validationError) {
|
setError(''); setTestResult(await onTest(draft, initialValue?.id))
|
||||||
setError(validationError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setError('')
|
|
||||||
const result = await onTest(draft, initialValue?.id)
|
|
||||||
setTestResult(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGoogleDriveAuth() {
|
async function handleGoogleDriveAuth() {
|
||||||
const validationError = validate(draft)
|
const e = validate(draft); if (e) { setError(e); return }
|
||||||
if (validationError) {
|
setError(''); await onGoogleDriveAuth(draft, initialValue?.id)
|
||||||
setError(validationError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setError('')
|
|
||||||
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() {
|
function renderStaticFields() {
|
||||||
return fieldConfigs.map((field) => {
|
return staticFields.map((field) => {
|
||||||
const value = draft.config[field.key]
|
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 (
|
return (
|
||||||
<div key={field.key}>
|
<div key={field.key}>
|
||||||
<Typography.Text>
|
<Typography.Text>{field.label}{field.required ? ' *' : ''}</Typography.Text>
|
||||||
{field.label}
|
|
||||||
{field.required ? ' *' : ''}
|
|
||||||
</Typography.Text>
|
|
||||||
{field.type === 'switch' ? (
|
{field.type === 'switch' ? (
|
||||||
<Space align="center" size="medium">
|
<Space align="center" size="medium">
|
||||||
<Switch checked={Boolean(normalizedValue)} onChange={(checked) => updateConfig(field.key, checked)} />
|
<Switch checked={Boolean(normalized)} onChange={(v) => updateConfig(field.key, v)} />
|
||||||
{field.description ? <Typography.Text type="secondary">{field.description}</Typography.Text> : null}
|
{field.description && <Typography.Text type="secondary">{field.description}</Typography.Text>}
|
||||||
</Space>
|
</Space>
|
||||||
) : field.type === 'password' ? (
|
) : field.type === 'password' ? (
|
||||||
<Input.Password
|
<Input.Password value={String(normalized)} placeholder={field.placeholder} onChange={(v) => updateConfig(field.key, v)} />
|
||||||
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)} />
|
<Input value={String(normalized)} placeholder={field.placeholder} onChange={(v) => updateConfig(field.key, v)} />
|
||||||
|
)}
|
||||||
|
{field.description && field.type !== 'switch' && (
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>{field.description}</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
{initialValue?.maskedFields?.includes(field.key) && !draft.config[field.key] && (
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>已存在敏感配置,留空则保持不变。</Typography.Paragraph>
|
||||||
)}
|
)}
|
||||||
{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>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 渲染动态字段(rclone 后端)
|
||||||
|
function renderDynamicFields() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>远端路径</Typography.Text>
|
||||||
|
<Input value={(draft.config.root as string) || ''} placeholder="如 /backups 或 bucket 名" onChange={(v) => updateConfig('root', v)} />
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>远端根路径、桶名或挂载点,留空使用根目录</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
{dynamicBackend && dynamicBackend.options.length > 0 && dynamicBackend.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={(v) => updateConfig(opt.key, v)} />
|
||||||
|
) : (
|
||||||
|
<Input value={(draft.config[opt.key] as string) || ''} placeholder={opt.label} onChange={(v) => updateConfig(opt.key, v)} />
|
||||||
|
)}
|
||||||
|
{opt.label && (
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 2, fontSize: 12 }} ellipsis={{ rows: 2, expandable: true }}>{opt.label}</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer width={560} title={initialValue ? '编辑存储目标' : '新建存储目标'} visible={visible} onCancel={onCancel} unmountOnExit={false}>
|
||||||
width={560}
|
|
||||||
title={initialValue ? '编辑存储目标' : '新建存储目标'}
|
|
||||||
visible={visible}
|
|
||||||
onCancel={onCancel}
|
|
||||||
unmountOnExit={false}
|
|
||||||
>
|
|
||||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
{error ? <Alert type="error" content={error} /> : <Alert type="info" content="存储目标提供备份文件的最终去向,请确保服务端网络连通性并通过测试。" />}
|
{error ? <Alert type="error" content={error} /> : <Alert type="info" content="存储目标提供备份文件的最终去向,请确保服务端网络连通性并通过测试。" />}
|
||||||
{testResult ? <Alert type={testResult.success ? 'success' : 'warning'} content={testResult.message} /> : null}
|
{testResult && <Alert type={testResult.success ? 'success' : 'warning'} content={testResult.message} />}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text>名称</Typography.Text>
|
<Typography.Text>名称</Typography.Text>
|
||||||
<Input value={draft.name} placeholder="例如:生产环境 MinIO" onChange={(value) => setDraft((current) => ({ ...current, name: value }))} />
|
<Input value={draft.name} placeholder="例如:生产环境 MinIO" onChange={(v) => setDraft((c) => ({ ...c, name: v }))} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text>类型</Typography.Text>
|
<Typography.Text>存储类型</Typography.Text>
|
||||||
<Select
|
<Select
|
||||||
value={draft.type}
|
showSearch
|
||||||
options={storageTargetTypeOptions as unknown as { label: string; value: string }[]}
|
value={draft.type || undefined}
|
||||||
|
placeholder="搜索存储类型(如 SFTP、Azure Blob、Dropbox...)"
|
||||||
|
options={allTypeOptions}
|
||||||
|
filterOption={(input, option) => {
|
||||||
|
const label = (option?.props?.children ?? option?.props?.label ?? '') as string
|
||||||
|
return label.toLowerCase().includes(input.toLowerCase())
|
||||||
|
}}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
const nextType = value as StorageTargetType
|
setDraft((c) => ({ ...c, type: value as string, config: {} }))
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
type: nextType,
|
|
||||||
config: {},
|
|
||||||
}))
|
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -321,16 +194,12 @@ export function StorageTargetFormDrawer({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text>描述</Typography.Text>
|
<Typography.Text>描述</Typography.Text>
|
||||||
<Input.TextArea
|
<Input.TextArea value={draft.description} placeholder="可选描述" onChange={(v) => setDraft((c) => ({ ...c, description: v }))} />
|
||||||
value={draft.description}
|
|
||||||
placeholder="可选描述,例如备份上传到 NAS 或 Google Drive"
|
|
||||||
onChange={(value) => setDraft((current) => ({ ...current, description: value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Space align="center" size="medium">
|
<Space align="center" size="medium">
|
||||||
<Typography.Text>启用</Typography.Text>
|
<Typography.Text>启用</Typography.Text>
|
||||||
<Switch checked={draft.enabled} onChange={(checked) => setDraft((current) => ({ ...current, enabled: checked }))} />
|
<Switch checked={draft.enabled} onChange={(v) => setDraft((c) => ({ ...c, enabled: v }))} />
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Divider orientation="left">环境配置</Divider>
|
<Divider orientation="left">环境配置</Divider>
|
||||||
@@ -340,22 +209,18 @@ export function StorageTargetFormDrawer({
|
|||||||
{getStorageTargetTypeLabel(draft.type)}
|
{getStorageTargetTypeLabel(draft.type)}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
{draft.type === 'rclone' ? renderRcloneFields() : renderStaticFields()}
|
{isDynamicType ? renderDynamicFields() : renderStaticFields()}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Space>
|
<Space>
|
||||||
<Button loading={testing} onClick={handleTest}>
|
<Button loading={testing} onClick={handleTest}>测试连接</Button>
|
||||||
测试连接
|
{draft.type === 'google_drive' && (
|
||||||
</Button>
|
|
||||||
{draft.type === 'google_drive' ? (
|
|
||||||
<Button type="outline" onClick={handleGoogleDriveAuth}>
|
<Button type="outline" onClick={handleGoogleDriveAuth}>
|
||||||
{initialValue ? '重新授权 Google Drive' : '发起 Google Drive 授权'}
|
{initialValue ? '重新授权 Google Drive' : '发起 Google Drive 授权'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
<Button type="primary" loading={loading} onClick={handleSubmit}>
|
<Button type="primary" loading={loading} onClick={handleSubmit}>保存</Button>
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -1,298 +1,82 @@
|
|||||||
import type { StorageTargetFieldConfig, StorageTargetType } from '../../types/storage-targets'
|
import type { StorageTargetFieldConfig, StorageTargetType } from '../../types/storage-targets'
|
||||||
|
|
||||||
const FIELD_CONFIG_MAP: Record<StorageTargetType, StorageTargetFieldConfig[]> = {
|
// 内置类型的静态字段配置(定制化配置结构)
|
||||||
|
const BUILTIN_FIELD_CONFIG: Record<string, StorageTargetFieldConfig[]> = {
|
||||||
local_disk: [
|
local_disk: [
|
||||||
{
|
{ key: 'basePath', label: '基础目录', type: 'input', required: true, placeholder: '/data/backups', description: 'BackupX 将在该目录下创建和管理备份文件。' },
|
||||||
key: 'basePath',
|
|
||||||
label: '基础目录',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
placeholder: '/data/backups',
|
|
||||||
description: 'BackupX 将在该目录下创建和管理备份文件。',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
s3: [
|
s3: [
|
||||||
{
|
{ key: 'endpoint', label: 'Endpoint', type: 'input', required: true, placeholder: 'https://s3.amazonaws.com' },
|
||||||
key: 'endpoint',
|
{ key: 'region', label: '区域', type: 'input', required: true, placeholder: 'ap-east-1' },
|
||||||
label: 'Endpoint',
|
{ key: 'bucket', label: 'Bucket', type: 'input', required: true, placeholder: 'backupx-prod' },
|
||||||
type: 'input',
|
{ key: 'accessKeyId', label: 'Access Key ID', type: 'input', required: true, sensitive: true, placeholder: 'AKIA...' },
|
||||||
required: true,
|
{ key: 'secretAccessKey', label: 'Secret Access Key', type: 'password', required: true, sensitive: true },
|
||||||
placeholder: 'https://s3.amazonaws.com',
|
{ key: 'forcePathStyle', label: '强制 Path Style', type: 'switch', description: 'MinIO 等兼容存储需要开启。' },
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'region',
|
|
||||||
label: '区域',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'ap-east-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bucket',
|
|
||||||
label: 'Bucket',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'backupx-prod',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'accessKeyId',
|
|
||||||
label: 'Access Key ID',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: 'AKIA...',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'secretAccessKey',
|
|
||||||
label: 'Secret Access Key',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: '输入新的 Secret Access Key',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'forcePathStyle',
|
|
||||||
label: '强制 Path Style',
|
|
||||||
type: 'switch',
|
|
||||||
description: 'MinIO 或部分兼容对象存储通常需要开启。',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
webdav: [
|
webdav: [
|
||||||
{
|
{ key: 'endpoint', label: 'WebDAV 地址', type: 'input', required: true, placeholder: 'https://dav.example.com/...' },
|
||||||
key: 'endpoint',
|
{ key: 'username', label: '用户名', type: 'input', required: true },
|
||||||
label: 'WebDAV 地址',
|
{ key: 'password', label: '密码', type: 'password', required: true, sensitive: true },
|
||||||
type: 'input',
|
{ key: 'basePath', label: '基础目录', type: 'input', placeholder: '/backupx' },
|
||||||
required: true,
|
|
||||||
placeholder: 'https://dav.example.com/remote.php/dav/files/admin',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'username',
|
|
||||||
label: '用户名',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'admin',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'password',
|
|
||||||
label: '密码',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: '输入新的 WebDAV 密码',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'basePath',
|
|
||||||
label: '基础目录',
|
|
||||||
type: 'input',
|
|
||||||
placeholder: '/backupx',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
google_drive: [
|
google_drive: [
|
||||||
{
|
{ key: 'clientId', label: 'Client ID', type: 'input', required: true, sensitive: true },
|
||||||
key: 'clientId',
|
{ key: 'clientSecret', label: 'Client Secret', type: 'password', required: true, sensitive: true },
|
||||||
label: 'Client ID',
|
{ key: 'folderId', label: '目标文件夹 ID', type: 'input', placeholder: '留空则使用根目录' },
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: 'Google OAuth Client ID',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'clientSecret',
|
|
||||||
label: 'Client Secret',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: '输入新的 Google Client Secret',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'folderId',
|
|
||||||
label: '目标文件夹 ID',
|
|
||||||
type: 'input',
|
|
||||||
placeholder: '留空则使用根目录',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
aliyun_oss: [
|
aliyun_oss: [
|
||||||
{
|
{ key: 'region', label: '区域', type: 'input', required: true, placeholder: 'cn-hangzhou' },
|
||||||
key: 'region',
|
{ key: 'bucket', label: 'Bucket', type: 'input', required: true },
|
||||||
label: '区域 (Region)',
|
{ key: 'accessKeyId', label: 'AccessKey ID', type: 'input', required: true, sensitive: true },
|
||||||
type: 'input',
|
{ key: 'secretAccessKey', label: 'AccessKey Secret', type: 'password', required: true, sensitive: true },
|
||||||
required: true,
|
{ key: 'internalNetwork', label: '使用内网', type: 'switch' },
|
||||||
placeholder: 'cn-hangzhou',
|
|
||||||
description: '如 cn-hangzhou, cn-shanghai, cn-beijing, cn-shenzhen 等。系统会自动组装 Endpoint。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bucket',
|
|
||||||
label: 'Bucket',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'my-backup-bucket',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'accessKeyId',
|
|
||||||
label: 'AccessKey ID',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: 'LTAI...',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'secretAccessKey',
|
|
||||||
label: 'AccessKey Secret',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: '输入新的 AccessKey Secret',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'internalNetwork',
|
|
||||||
label: '使用内网 Endpoint',
|
|
||||||
type: 'switch',
|
|
||||||
description: '同一区域的 ECS 实例可启用内网传输,节省流量费用。',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
tencent_cos: [
|
tencent_cos: [
|
||||||
{
|
{ key: 'region', label: '区域', type: 'input', required: true, placeholder: 'ap-guangzhou' },
|
||||||
key: 'region',
|
{ key: 'bucket', label: 'Bucket', type: 'input', required: true, placeholder: 'backup-1250000000' },
|
||||||
label: '区域 (Region)',
|
{ key: 'accessKeyId', label: 'SecretId', type: 'input', required: true, sensitive: true },
|
||||||
type: 'input',
|
{ key: 'secretAccessKey', label: 'SecretKey', type: 'password', required: true, sensitive: true },
|
||||||
required: true,
|
|
||||||
placeholder: 'ap-guangzhou',
|
|
||||||
description: '如 ap-guangzhou, ap-shanghai, ap-beijing, ap-chengdu 等。系统会自动组装 Endpoint。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bucket',
|
|
||||||
label: 'Bucket',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'backup-1250000000',
|
|
||||||
description: '格式为 BucketName-APPID,如 backup-1250000000。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'accessKeyId',
|
|
||||||
label: 'SecretId',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: 'AKIDxxxxxxxx',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'secretAccessKey',
|
|
||||||
label: 'SecretKey',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: '输入新的 SecretKey',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
qiniu_kodo: [
|
qiniu_kodo: [
|
||||||
{
|
{ key: 'region', label: '区域', type: 'input', required: true, placeholder: 'z0' },
|
||||||
key: 'region',
|
{ key: 'bucket', label: 'Bucket', type: 'input', required: true },
|
||||||
label: '区域 (Region)',
|
{ key: 'accessKeyId', label: 'AccessKey', type: 'input', required: true, sensitive: true },
|
||||||
type: 'input',
|
{ key: 'secretAccessKey', label: 'SecretKey', type: 'password', required: true, sensitive: true },
|
||||||
required: true,
|
|
||||||
placeholder: 'z0',
|
|
||||||
description: '支持 z0(华东), cn-east-2(华东-浙江2), z1(华北), z2(华南), na0(北美), as0(东南亚)。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bucket',
|
|
||||||
label: 'Bucket',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'my-backup',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'accessKeyId',
|
|
||||||
label: 'AccessKey',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: '七牛云 AccessKey',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'secretAccessKey',
|
|
||||||
label: 'SecretKey',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: '输入新的 SecretKey',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
rclone: [], // 动态表单,字段从 API 获取(见 StorageTargetFormDrawer)
|
|
||||||
ftp: [
|
ftp: [
|
||||||
{
|
{ key: 'host', label: '主机地址', type: 'input', required: true, placeholder: 'ftp.example.com' },
|
||||||
key: 'host',
|
{ key: 'port', label: '端口', type: 'input', placeholder: '21' },
|
||||||
label: '主机地址',
|
{ key: 'username', label: '用户名', type: 'input', required: true },
|
||||||
type: 'input',
|
{ key: 'password', label: '密码', type: 'password', required: true, sensitive: true },
|
||||||
required: true,
|
{ key: 'basePath', label: '基础目录', type: 'input', placeholder: '/backups' },
|
||||||
placeholder: 'ftp.example.com',
|
{ key: 'useTLS', label: 'TLS (FTPS)', type: 'switch' },
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'port',
|
|
||||||
label: '端口',
|
|
||||||
type: 'input',
|
|
||||||
placeholder: '21',
|
|
||||||
description: '默认 FTP 端口为 21。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'username',
|
|
||||||
label: '用户名',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'backup_user',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'password',
|
|
||||||
label: '密码',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: '输入新的 FTP 密码',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'basePath',
|
|
||||||
label: '基础目录',
|
|
||||||
type: 'input',
|
|
||||||
placeholder: '/backups',
|
|
||||||
description: 'FTP 服务器上的目标目录,留空使用根目录。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'useTLS',
|
|
||||||
label: '使用 TLS (FTPS)',
|
|
||||||
type: 'switch',
|
|
||||||
description: '启用 Explicit TLS 加密连接。',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStorageTargetFieldConfigs(type: StorageTargetType) {
|
const BUILTIN_TYPES = new Set(Object.keys(BUILTIN_FIELD_CONFIG))
|
||||||
return FIELD_CONFIG_MAP[type]
|
|
||||||
|
/** 是否为内置类型 */
|
||||||
|
export function isBuiltinType(type: StorageTargetType): boolean {
|
||||||
|
return BUILTIN_TYPES.has(type)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStorageTargetTypeLabel(type: StorageTargetType) {
|
/** 获取静态字段配置 */
|
||||||
switch (type) {
|
export function getStorageTargetFieldConfigs(type: StorageTargetType): StorageTargetFieldConfig[] {
|
||||||
case 'local_disk':
|
return BUILTIN_FIELD_CONFIG[type] ?? []
|
||||||
return '本地磁盘'
|
|
||||||
case 'google_drive':
|
|
||||||
return 'Google Drive'
|
|
||||||
case 's3':
|
|
||||||
return 'S3 Compatible'
|
|
||||||
case 'webdav':
|
|
||||||
return 'WebDAV'
|
|
||||||
case 'aliyun_oss':
|
|
||||||
return '阿里云 OSS'
|
|
||||||
case 'tencent_cos':
|
|
||||||
return '腾讯云 COS'
|
|
||||||
case 'qiniu_kodo':
|
|
||||||
return '七牛云 Kodo'
|
|
||||||
case 'ftp':
|
|
||||||
return 'FTP'
|
|
||||||
case 'rclone':
|
|
||||||
return 'Rclone (70+ 后端)'
|
|
||||||
default:
|
|
||||||
return type
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storageTargetTypeOptions = [
|
const BUILTIN_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',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStorageTargetTypeLabel(type: StorageTargetType): string {
|
||||||
|
return BUILTIN_LABELS[type] || type.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内置类型选项(下拉框"常用"分组) */
|
||||||
|
export const builtinTypeOptions = [
|
||||||
{ label: '本地磁盘', value: 'local_disk' },
|
{ label: '本地磁盘', value: 'local_disk' },
|
||||||
{ label: '阿里云 OSS', value: 'aliyun_oss' },
|
{ label: '阿里云 OSS', value: 'aliyun_oss' },
|
||||||
{ label: '腾讯云 COS', value: 'tencent_cos' },
|
{ label: '腾讯云 COS', value: 'tencent_cos' },
|
||||||
@@ -301,5 +85,4 @@ 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 — SFTP / Azure / Dropbox / OneDrive 等 70+ 后端', value: 'rclone' },
|
]
|
||||||
] as const
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type StorageTargetType = 'local_disk' | 'google_drive' | 's3' | 'webdav' | 'aliyun_oss' | 'tencent_cos' | 'qiniu_kodo' | 'ftp' | 'rclone'
|
// 内置类型 + 全部 rclone 后端名(sftp, azureblob, dropbox 等)
|
||||||
|
export type StorageTargetType = string
|
||||||
export type StorageTestStatus = 'unknown' | 'success' | 'failed'
|
export type StorageTestStatus = 'unknown' | 'success' | 'failed'
|
||||||
export type StorageFieldType = 'input' | 'password' | 'switch'
|
export type StorageFieldType = 'input' | 'password' | 'switch'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user