mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-07 18:59:34 +08:00
first commit
This commit is contained in:
235
web/src/components/storage-targets/StorageTargetFormDrawer.tsx
Normal file
235
web/src/components/storage-targets/StorageTargetFormDrawer.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
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 type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetType } from '../../types/storage-targets'
|
||||
|
||||
interface StorageTargetFormDrawerProps {
|
||||
visible: boolean
|
||||
loading: boolean
|
||||
testing: boolean
|
||||
initialValue: StorageTargetDetail | null
|
||||
onCancel: () => void
|
||||
onSubmit: (value: StorageTargetPayload, targetId?: number) => Promise<void>
|
||||
onTest: (value: StorageTargetPayload, targetId?: number) => Promise<StorageConnectionTestResult>
|
||||
onGoogleDriveAuth: (value: StorageTargetPayload, targetId?: number) => Promise<void>
|
||||
}
|
||||
|
||||
function createEmptyDraft(type: StorageTargetType = 'local_disk'): StorageTargetPayload {
|
||||
return {
|
||||
name: '',
|
||||
type,
|
||||
description: '',
|
||||
enabled: true,
|
||||
config: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function StorageTargetFormDrawer({
|
||||
visible,
|
||||
loading,
|
||||
testing,
|
||||
initialValue,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
onTest,
|
||||
onGoogleDriveAuth,
|
||||
}: StorageTargetFormDrawerProps) {
|
||||
const [draft, setDraft] = useState<StorageTargetPayload>(createEmptyDraft())
|
||||
const [error, setError] = useState('')
|
||||
const [testResult, setTestResult] = useState<StorageConnectionTestResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
if (!initialValue) {
|
||||
setDraft(createEmptyDraft())
|
||||
setError('')
|
||||
setTestResult(null)
|
||||
return
|
||||
}
|
||||
setDraft({
|
||||
name: initialValue.name,
|
||||
type: initialValue.type,
|
||||
description: initialValue.description,
|
||||
enabled: initialValue.enabled,
|
||||
config: { ...initialValue.config },
|
||||
})
|
||||
setError('')
|
||||
setTestResult(null)
|
||||
}, [initialValue, visible])
|
||||
|
||||
const fieldConfigs = useMemo(() => getStorageTargetFieldConfigs(draft.type), [draft.type])
|
||||
|
||||
function updateConfig(key: string, value: string | boolean) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
config: {
|
||||
...current.config,
|
||||
[key]: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function validate(value: StorageTargetPayload) {
|
||||
if (!value.name.trim()) {
|
||||
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 ''
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const validationError = validate(draft)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
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)
|
||||
}
|
||||
|
||||
async function handleGoogleDriveAuth() {
|
||||
const validationError = validate(draft)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
await onGoogleDriveAuth(draft, initialValue?.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width={560}
|
||||
title={initialValue ? '编辑存储目标' : '新建存储目标'}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
unmountOnExit={false}
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{error ? <Alert type="error" content={error} /> : <Alert type="info" content="存储目标提供备份文件的最终去向,请确保服务端网络连通性并通过测试。" />}
|
||||
{testResult ? <Alert type={testResult.success ? 'success' : 'warning'} content={testResult.message} /> : null}
|
||||
|
||||
<div>
|
||||
<Typography.Text>名称</Typography.Text>
|
||||
<Input value={draft.name} placeholder="例如:生产环境 MinIO" onChange={(value) => setDraft((current) => ({ ...current, name: value }))} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography.Text>类型</Typography.Text>
|
||||
<Select
|
||||
value={draft.type}
|
||||
options={storageTargetTypeOptions as unknown as { label: string; value: string }[]}
|
||||
onChange={(value) => {
|
||||
const nextType = value as StorageTargetType
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
type: nextType,
|
||||
config: {},
|
||||
}))
|
||||
setTestResult(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography.Text>描述</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={draft.description}
|
||||
placeholder="可选描述,例如备份上传到 NAS 或 Google Drive"
|
||||
onChange={(value) => setDraft((current) => ({ ...current, description: value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Space align="center" size="medium">
|
||||
<Typography.Text>启用</Typography.Text>
|
||||
<Switch checked={draft.enabled} onChange={(checked) => setDraft((current) => ({ ...current, enabled: checked }))} />
|
||||
</Space>
|
||||
|
||||
<Divider orientation="left">环境配置</Divider>
|
||||
|
||||
<div>
|
||||
<Typography.Title heading={6} style={{ marginTop: 0, color: 'var(--color-text-2)' }}>
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Button loading={testing} onClick={handleTest}>
|
||||
测试连接
|
||||
</Button>
|
||||
{draft.type === 'google_drive' ? (
|
||||
<Button type="outline" onClick={handleGoogleDriveAuth}>
|
||||
{initialValue ? '重新授权 Google Drive' : '发起 Google Drive 授权'}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" loading={loading} onClick={handleSubmit}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
15
web/src/components/storage-targets/field-config.test.ts
Normal file
15
web/src/components/storage-targets/field-config.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getStorageTargetFieldConfigs, getStorageTargetTypeLabel } from './field-config'
|
||||
|
||||
describe('storage target field config', () => {
|
||||
it('returns local disk field config', () => {
|
||||
const fields = getStorageTargetFieldConfigs('local_disk')
|
||||
expect(fields).toHaveLength(1)
|
||||
expect(fields[0]?.key).toBe('basePath')
|
||||
})
|
||||
|
||||
it('returns readable type labels', () => {
|
||||
expect(getStorageTargetTypeLabel('google_drive')).toBe('Google Drive')
|
||||
expect(getStorageTargetTypeLabel('webdav')).toBe('WebDAV')
|
||||
})
|
||||
})
|
||||
254
web/src/components/storage-targets/field-config.ts
Normal file
254
web/src/components/storage-targets/field-config.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import type { StorageTargetFieldConfig, StorageTargetType } from '../../types/storage-targets'
|
||||
|
||||
const FIELD_CONFIG_MAP: Record<StorageTargetType, StorageTargetFieldConfig[]> = {
|
||||
local_disk: [
|
||||
{
|
||||
key: 'basePath',
|
||||
label: '基础目录',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: '/data/backups',
|
||||
description: 'BackupX 将在该目录下创建和管理备份文件。',
|
||||
},
|
||||
],
|
||||
s3: [
|
||||
{
|
||||
key: 'endpoint',
|
||||
label: 'Endpoint',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: 'https://s3.amazonaws.com',
|
||||
},
|
||||
{
|
||||
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: [
|
||||
{
|
||||
key: 'endpoint',
|
||||
label: 'WebDAV 地址',
|
||||
type: 'input',
|
||||
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: [
|
||||
{
|
||||
key: 'clientId',
|
||||
label: 'Client ID',
|
||||
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: [
|
||||
{
|
||||
key: 'region',
|
||||
label: '区域 (Region)',
|
||||
type: 'input',
|
||||
required: true,
|
||||
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: [
|
||||
{
|
||||
key: 'region',
|
||||
label: '区域 (Region)',
|
||||
type: 'input',
|
||||
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: [
|
||||
{
|
||||
key: 'region',
|
||||
label: '区域 (Region)',
|
||||
type: 'input',
|
||||
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',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export function getStorageTargetFieldConfigs(type: StorageTargetType) {
|
||||
return FIELD_CONFIG_MAP[type]
|
||||
}
|
||||
|
||||
export function getStorageTargetTypeLabel(type: StorageTargetType) {
|
||||
switch (type) {
|
||||
case 'local_disk':
|
||||
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'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
export const storageTargetTypeOptions = [
|
||||
{ label: '本地磁盘', value: 'local_disk' },
|
||||
{ label: '阿里云 OSS', value: 'aliyun_oss' },
|
||||
{ label: '腾讯云 COS', value: 'tencent_cos' },
|
||||
{ label: '七牛云 Kodo', value: 'qiniu_kodo' },
|
||||
{ label: 'S3 Compatible', value: 's3' },
|
||||
{ label: 'Google Drive', value: 'google_drive' },
|
||||
{ label: 'WebDAV', value: 'webdav' },
|
||||
] as const
|
||||
Reference in New Issue
Block a user