first commit

This commit is contained in:
Awuqing
2026-03-17 13:29:09 +08:00
commit eadd3f8961
219 changed files with 22394 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import { ReactNode, useEffect, useRef } from 'react'
import { FullPageLoading } from './FullPageLoading'
import { useAuthStore } from '../stores/auth'
interface AuthBootstrapProps {
children: ReactNode
}
export function AuthBootstrap({ children }: AuthBootstrapProps) {
const bootstrap = useAuthStore((state) => state.bootstrap)
const bootstrapped = useAuthStore((state) => state.bootstrapped)
const startedRef = useRef(false)
useEffect(() => {
if (startedRef.current) {
return
}
startedRef.current = true
void bootstrap()
}, [bootstrap])
if (!bootstrapped) {
return <FullPageLoading tip="正在初始化登录状态..." />
}
return <>{children}</>
}

View File

@@ -0,0 +1,197 @@
import { Input, Space, Switch, Tabs, Typography, Radio, Checkbox, Select } from '@arco-design/web-react'
import { useEffect, useState } from 'react'
export interface CronInputProps {
value?: string
onChange?: (value: string) => void
}
const DEFAULT_CRON = '* * * * *'
type CronPart = 'minute' | 'hour' | 'day' | 'month' | 'week'
interface CronState {
minute: string
hour: string
day: string
month: string
week: string
}
function parseCron(expr: string): CronState {
const parts = (expr || DEFAULT_CRON).trim().split(/\s+/)
return {
minute: parts[0] || '*',
hour: parts[1] || '*',
day: parts[2] || '*',
month: parts[3] || '*',
week: parts[4] || '*',
}
}
function stringifyCron(state: CronState): string {
return `${state.minute} ${state.hour} ${state.day} ${state.month} ${state.week}`
}
function generateOptions(min: number, max: number) {
return Array.from({ length: max - min + 1 }, (_, i) => ({
label: String(i + min),
value: String(i + min),
}))
}
const MINUTES_OPTIONS = generateOptions(0, 59)
const HOURS_OPTIONS = generateOptions(0, 23)
const DAYS_OPTIONS = generateOptions(1, 31)
const MONTHS_OPTIONS = generateOptions(1, 12)
const WEEKS_OPTIONS = [
{ label: '星期日', value: '0' },
{ label: '星期一', value: '1' },
{ label: '星期二', value: '2' },
{ label: '星期三', value: '3' },
{ label: '星期四', value: '4' },
{ label: '星期五', value: '5' },
{ label: '星期六', value: '6' },
]
export function CronInput({ value, onChange }: CronInputProps) {
const [internalValue, setInternalValue] = useState(value || DEFAULT_CRON)
const [isAdvanced, setIsAdvanced] = useState(false)
const [state, setState] = useState<CronState>(parseCron(internalValue))
// Sync prop to internal state
useEffect(() => {
if (value !== undefined && value !== internalValue) {
setInternalValue(value || DEFAULT_CRON)
if (!isAdvanced) {
setState(parseCron(value || DEFAULT_CRON))
}
}
}, [value, isAdvanced, internalValue])
const notifyChange = (nextValue: string) => {
setInternalValue(nextValue)
if (onChange) {
onChange(nextValue)
}
}
const handleStateChange = (part: CronPart, val: string) => {
const nextState = { ...state, [part]: val }
setState(nextState)
notifyChange(stringifyCron(nextState))
}
const renderPartTab = (
part: CronPart,
title: string,
options: { label: string; value: string }[],
allowAnyVal = '*',
) => {
const currentVal = state[part]
const isAny = currentVal === allowAnyVal || currentVal === '*' || currentVal === '?'
const isSpecific = !isAny && !currentVal.includes('/') && !currentVal.includes('-')
// For simplicity in this visual editor, we only support "every" (*) and "specific values" (1,2,3).
const type = isAny ? 'any' : 'specific'
const specificValues = isSpecific ? currentVal.split(',') : []
return (
<div style={{ padding: '16px 0' }}>
<Radio.Group
direction="vertical"
value={type}
onChange={(val) => {
if (val === 'any') {
handleStateChange(part, allowAnyVal)
} else {
handleStateChange(part, options[0].value) // Default to first valid item
}
}}
>
<Radio value="any">
<Typography.Text> ({allowAnyVal}) - {title}</Typography.Text>
</Radio>
<Radio value="specific">
<Typography.Text>{title}</Typography.Text>
</Radio>
</Radio.Group>
{type === 'specific' && (
<div style={{ paddingLeft: 24, marginTop: 12 }}>
<Select
mode="multiple"
placeholder={`请选择${title}`}
value={specificValues}
options={options}
onChange={(vals: string[]) => {
if (vals.length === 0) {
handleStateChange(part, allowAnyVal)
} else {
// Sort numerically to keep things neat
const sorted = [...vals].sort((a, b) => Number(a) - Number(b))
handleStateChange(part, sorted.join(','))
}
}}
style={{ width: '100%', maxWidth: 400 }}
allowClear
/>
</div>
)}
</div>
)
}
return (
<div className="cron-input-container">
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Input
value={internalValue}
onChange={(val) => {
setInternalValue(val)
if (isAdvanced && onChange) {
onChange(val)
}
}}
readOnly={!isAdvanced}
style={{ width: 240, fontFamily: 'monospace' }}
placeholder="* * * * *"
/>
<Space>
<Typography.Text type="secondary"> ()</Typography.Text>
<Switch
checked={isAdvanced}
onChange={(checked) => {
setIsAdvanced(checked)
if (!checked) {
// When switching back to visual, parse the current raw value
setState(parseCron(internalValue))
notifyChange(stringifyCron(parseCron(internalValue)))
}
}}
/>
</Space>
</div>
{!isAdvanced && (
<Tabs type="card-gutter" size="small">
<Tabs.TabPane key="minute" title="分钟">
{renderPartTab('minute', '分钟', MINUTES_OPTIONS, '*')}
</Tabs.TabPane>
<Tabs.TabPane key="hour" title="小时">
{renderPartTab('hour', '小时', HOURS_OPTIONS, '*')}
</Tabs.TabPane>
<Tabs.TabPane key="day" title="日">
{renderPartTab('day', '日', DAYS_OPTIONS, '*')}
</Tabs.TabPane>
<Tabs.TabPane key="month" title="月">
{renderPartTab('month', '月', MONTHS_OPTIONS, '*')}
</Tabs.TabPane>
<Tabs.TabPane key="week" title="周">
{renderPartTab('week', '周', WEEKS_OPTIONS, '*')}
</Tabs.TabPane>
</Tabs>
)}
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { CronInput } from './CronInput'
export type { CronInputProps } from './CronInput'

View File

@@ -0,0 +1,16 @@
import { Space, Spin, Typography } from '@arco-design/web-react'
interface FullPageLoadingProps {
tip: string
}
export function FullPageLoading({ tip }: FullPageLoadingProps) {
return (
<div className="full-page-shell">
<Space direction="vertical" size="large" align="center">
<Spin size={32} />
<Typography.Text>{tip}</Typography.Text>
</Space>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { AuthGuard } from './auth-guard';
import { useAuthStore } from '../stores/auth';
function renderWithRoutes(initialEntry: string) {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/login" element={<div>login-page</div>} />
<Route
path="/"
element={
<AuthGuard>
<div>protected-page</div>
</AuthGuard>
}
/>
</Routes>
</MemoryRouter>,
);
}
describe('AuthGuard', () => {
beforeEach(() => {
useAuthStore.setState({
token: null,
user: null,
hydrated: true,
status: 'anonymous',
});
});
it('redirects anonymous users to login page', async () => {
renderWithRoutes('/');
expect(await screen.findByText('login-page')).toBeInTheDocument();
});
it('renders children for authenticated users', async () => {
useAuthStore.setState({
token: 'token',
user: {
id: 1,
username: 'admin',
displayName: '管理员',
role: 'admin',
},
hydrated: true,
status: 'authenticated',
});
renderWithRoutes('/');
expect(await screen.findByText('protected-page')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,25 @@
import { Spin } from '@arco-design/web-react';
import type { PropsWithChildren } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../stores/auth';
export function AuthGuard({ children }: PropsWithChildren) {
const hydrated = useAuthStore((state) => state.hydrated);
const status = useAuthStore((state) => state.status);
const location = useLocation();
if (!hydrated || status === 'bootstrapping' || status === 'idle') {
return (
<div className="fullscreen-center">
<Spin tip="正在加载登录状态..." />
</div>
);
}
if (status !== 'authenticated') {
return <Navigate to="/login" replace state={{ from: location }} />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,232 @@
import { Alert, Button, Descriptions, Drawer, Space, Spin, Tag, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
import { deleteBackupRecord, downloadBackupRecord, getBackupRecord, restoreBackupRecord, streamBackupRecordLogs } from '../../services/backup-records'
import type { BackupLogEvent, BackupRecordDetail, BackupRecordStatus } from '../../types/backup-records'
import { resolveErrorMessage } from '../../utils/error'
import { formatBytes, formatDateTime, formatDuration } from '../../utils/format'
interface BackupRecordLogDrawerProps {
visible: boolean
recordId?: number
onCancel: () => void
onChanged?: () => Promise<void> | void
}
function getStatusColor(status: BackupRecordStatus) {
switch (status) {
case 'success':
return 'green'
case 'failed':
return 'red'
default:
return 'arcoblue'
}
}
function buildLogText(record: BackupRecordDetail | null, events: BackupLogEvent[]) {
if (events.length > 0) {
return events.map((item) => `[${formatDateTime(item.timestamp)}] ${item.message}`).join('\n')
}
return record?.logContent ?? ''
}
export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }: BackupRecordLogDrawerProps) {
const [record, setRecord] = useState<BackupRecordDetail | null>(null)
const [events, setEvents] = useState<BackupLogEvent[]>([])
const [loading, setLoading] = useState(false)
const [acting, setActing] = useState(false)
const [error, setError] = useState('')
const [streamError, setStreamError] = useState('')
useEffect(() => {
if (!visible || !recordId) {
return
}
const currentRecordId = recordId
let active = true
let unsubscribe: (() => void) | null = null
async function loadRecordDetail() {
setLoading(true)
try {
const detail = await getBackupRecord(currentRecordId)
if (!active) {
return
}
setRecord(detail)
setEvents(detail.logEvents ?? [])
setError('')
setStreamError('')
if (detail.status === 'running') {
unsubscribe = streamBackupRecordLogs(currentRecordId, {
onEvent: (event) => {
if (!active) {
return
}
setEvents((current) => {
if (current.some((item) => item.sequence === event.sequence)) {
return current
}
return [...current, event]
})
if (event.completed) {
setRecord((current) => (current ? { ...current, status: event.status as BackupRecordStatus } : current))
}
},
onDone: () => {
if (!active) {
return
}
void (async () => {
try {
const latest = await getBackupRecord(currentRecordId)
if (active) {
setRecord(latest)
setEvents(latest.logEvents ?? [])
}
} catch (streamLoadError) {
if (active) {
setStreamError(resolveErrorMessage(streamLoadError, '刷新日志详情失败'))
}
}
})()
},
onError: (message) => {
if (active) {
setStreamError(message)
}
},
})
}
} catch (loadError) {
if (active) {
setError(resolveErrorMessage(loadError, '加载备份记录失败'))
}
} finally {
if (active) {
setLoading(false)
}
}
}
void loadRecordDetail()
return () => {
active = false
unsubscribe?.()
}
}, [recordId, visible])
const logText = useMemo(() => buildLogText(record, events), [events, record])
async function handleDownload() {
if (!recordId) {
return
}
setActing(true)
try {
const result = await downloadBackupRecord(recordId)
const url = window.URL.createObjectURL(result.blob)
const link = document.createElement('a')
link.href = url
link.download = result.fileName
link.click()
window.URL.revokeObjectURL(url)
} catch (downloadError) {
setStreamError(resolveErrorMessage(downloadError, '下载备份文件失败'))
} finally {
setActing(false)
}
}
async function handleRestore() {
if (!recordId) {
return
}
setActing(true)
try {
await restoreBackupRecord(recordId)
setStreamError('恢复命令已提交')
await onChanged?.()
} catch (restoreError) {
setStreamError(resolveErrorMessage(restoreError, '恢复备份失败'))
} finally {
setActing(false)
}
}
async function handleDelete() {
if (!recordId) {
return
}
if (!window.confirm('确定删除该备份记录及远端对象吗?')) {
return
}
setActing(true)
try {
await deleteBackupRecord(recordId)
await onChanged?.()
onCancel()
} catch (deleteError) {
setStreamError(resolveErrorMessage(deleteError, '删除备份记录失败'))
} finally {
setActing(false)
}
}
return (
<Drawer width={720} title="备份记录详情" visible={visible} onCancel={onCancel}>
{loading ? (
<Spin />
) : error ? (
<Alert type="error" content={error} />
) : record ? (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{streamError ? <Alert type="warning" content={streamError} /> : null}
<div>
<Typography.Title heading={6} style={{ marginTop: 0, marginBottom: 4 }}>
{record.taskName}
</Typography.Title>
<Space>
{record.status && (
<Tag color={getStatusColor(record.status)} bordered>
{record.status === 'success' ? '成功' : record.status === 'failed' ? '失败' : record.status === 'running' ? '执行中' : record.status}
</Tag>
)}
{record.storageTargetName && <Tag color="arcoblue" bordered>{record.storageTargetName}</Tag>}
</Space>
</div>
<Descriptions
column={1}
data={[
{ label: '文件名', value: record.fileName || '-' },
{ label: '文件大小', value: formatBytes(record.fileSize) },
{ label: '存储路径', value: record.storagePath || '-' },
{ label: '开始时间', value: formatDateTime(record.startedAt) },
{ label: '完成时间', value: formatDateTime(record.completedAt) },
{ label: '耗时', value: formatDuration(record.durationSeconds) },
{ label: '错误信息', value: record.errorMessage || '-' },
]}
/>
<Space>
<Button loading={acting} onClick={handleDownload}>
</Button>
<Button loading={acting} onClick={handleRestore}>
</Button>
<Button loading={acting} status="danger" onClick={handleDelete}>
</Button>
</Space>
<div>
<Typography.Title heading={6}></Typography.Title>
<div className="log-viewer">{logText || '暂无日志输出'}</div>
</div>
</Space>
) : null}
</Drawer>
)
}

View File

@@ -0,0 +1,63 @@
import { Descriptions, Drawer, Space, Tag, Typography } from '@arco-design/web-react'
import type { BackupTaskDetail } from '../../types/backup-tasks'
import { formatDateTime } from '../../utils/format'
import { getBackupTaskStatusColor, getBackupTaskStatusLabel, getBackupTaskTypeLabel } from './field-config'
interface BackupTaskDetailDrawerProps {
visible: boolean
task: BackupTaskDetail | null
onCancel: () => void
}
export function BackupTaskDetailDrawer({ visible, task, onCancel }: BackupTaskDetailDrawerProps) {
return (
<Drawer width={560} title="任务详情" visible={visible} onCancel={onCancel}>
{task ? (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title heading={6} style={{ marginTop: 0, marginBottom: 4 }}>
{task.name}
</Typography.Title>
<Space>
<Tag color="arcoblue" bordered>{getBackupTaskTypeLabel(task.type)}</Tag>
<Tag color={task.enabled ? 'green' : 'gray'} bordered>{task.enabled ? '已启用' : '已停用'}</Tag>
<Tag color={getBackupTaskStatusColor(task.lastStatus)} bordered>{getBackupTaskStatusLabel(task.lastStatus)}</Tag>
</Space>
</div>
<Descriptions
column={1}
border
data={[
{ label: 'Cron', value: task.cronExpr || '仅手动执行' },
{ label: '存储目标', value: task.storageTargetName || task.storageTargetId },
{ label: '保留天数', value: task.retentionDays },
{ label: '最大保留份数', value: task.maxBackups },
{ label: '压缩', value: task.compression },
{ label: '加密', value: task.encrypt ? '已启用' : '未启用' },
{ label: '最近执行', value: formatDateTime(task.lastRunAt) },
{ label: '创建时间', value: formatDateTime(task.createdAt) },
{ label: '更新时间', value: formatDateTime(task.updatedAt) },
]}
/>
{task.type === 'file' ? (
<Descriptions border column={1} data={[{ label: '源路径', value: task.sourcePath || '-' }, { label: '排除规则', value: task.excludePatterns.join(', ') || '-' }]} />
) : null}
{task.type === 'sqlite' ? <Descriptions border column={1} data={[{ label: 'SQLite 路径', value: task.dbPath || '-' }]} /> : null}
{task.type === 'mysql' || task.type === 'postgresql' ? (
<Descriptions
column={1}
border
data={[
{ label: '数据库主机', value: task.dbHost || '-' },
{ label: '数据库端口', value: task.dbPort || '-' },
{ label: '数据库用户', value: task.dbUser || '-' },
{ label: '数据库名称', value: task.dbName || '-' },
{ label: '数据库密码', value: task.maskedFields?.includes('dbPassword') ? '已配置' : '未配置' },
]}
/>
) : null}
</Space>
) : null}
</Drawer>
)
}

View File

@@ -0,0 +1,325 @@
import { Alert, Button, Divider, Drawer, Input, InputNumber, Select, Space, Steps, Switch, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
import { CronInput } from '../CronInput'
import type { StorageTargetSummary } from '../../types/storage-targets'
import type { BackupTaskDetail, BackupTaskPayload, BackupTaskType } from '../../types/backup-tasks'
import {
backupCompressionOptions,
backupTaskTypeOptions,
getDefaultPort,
isDatabaseBackupTask,
isFileBackupTask,
isSQLiteBackupTask,
} from './field-config'
interface BackupTaskFormDrawerProps {
visible: boolean
loading: boolean
initialValue: BackupTaskDetail | null
storageTargets: StorageTargetSummary[]
onCancel: () => void
onSubmit: (value: BackupTaskPayload, taskId?: number) => Promise<void>
}
function createEmptyDraft(storageTargetId?: number): BackupTaskPayload {
return {
name: '',
type: 'file',
enabled: true,
cronExpr: '',
sourcePath: '',
excludePatterns: [],
dbHost: '',
dbPort: 0,
dbUser: '',
dbPassword: '',
dbName: '',
dbPath: '',
storageTargetId: storageTargetId ?? 0,
nodeId: 0,
tags: '',
retentionDays: 30,
compression: 'gzip',
encrypt: false,
maxBackups: 10,
}
}
export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTargets, onCancel, onSubmit }: BackupTaskFormDrawerProps) {
const [draft, setDraft] = useState<BackupTaskPayload>(createEmptyDraft())
const [excludePatternsText, setExcludePatternsText] = useState('')
const [currentStep, setCurrentStep] = useState(0)
const [error, setError] = useState('')
useEffect(() => {
if (!visible) {
return
}
if (!initialValue) {
const nextDraft = createEmptyDraft(storageTargets[0]?.id)
setDraft(nextDraft)
setExcludePatternsText('')
setCurrentStep(0)
setError('')
return
}
setDraft({
name: initialValue.name,
type: initialValue.type,
enabled: initialValue.enabled,
cronExpr: initialValue.cronExpr,
sourcePath: initialValue.sourcePath,
excludePatterns: initialValue.excludePatterns,
dbHost: initialValue.dbHost,
dbPort: initialValue.dbPort,
dbUser: initialValue.dbUser,
dbPassword: '',
dbName: initialValue.dbName,
dbPath: initialValue.dbPath,
storageTargetId: initialValue.storageTargetId,
nodeId: (initialValue as any).nodeId ?? 0,
tags: (initialValue as any).tags ?? '',
retentionDays: initialValue.retentionDays,
compression: initialValue.compression,
encrypt: initialValue.encrypt,
maxBackups: initialValue.maxBackups,
})
setExcludePatternsText(initialValue.excludePatterns.join('\n'))
setCurrentStep(0)
setError('')
}, [initialValue, storageTargets, visible])
const storageTargetOptions = useMemo(
() => storageTargets.map((item) => ({ label: item.name, value: item.id, disabled: !item.enabled })),
[storageTargets],
)
function updateDraft(patch: Partial<BackupTaskPayload>) {
setDraft((current) => ({ ...current, ...patch }))
}
function updateTaskType(value: BackupTaskType) {
setDraft((current) => ({
...current,
type: value,
sourcePath: value === 'file' ? current.sourcePath : '',
excludePatterns: value === 'file' ? current.excludePatterns : [],
dbHost: value === 'mysql' || value === 'postgresql' ? current.dbHost : '',
dbPort: value === 'mysql' || value === 'postgresql' ? current.dbPort || getDefaultPort(value) : 0,
dbUser: value === 'mysql' || value === 'postgresql' ? current.dbUser : '',
dbPassword: value === 'mysql' || value === 'postgresql' ? current.dbPassword : '',
dbName: value === 'mysql' || value === 'postgresql' ? current.dbName : '',
dbPath: value === 'sqlite' ? current.dbPath : '',
}))
if (value !== 'file') {
setExcludePatternsText('')
}
}
function validate(value: BackupTaskPayload) {
if (!value.name.trim()) {
return '请输入任务名称'
}
if (!value.storageTargetId) {
return '请选择存储目标'
}
if (value.cronExpr.trim() && value.cronExpr.trim().split(/\s+/).length < 5) {
return 'Cron 表达式至少需要 5 段'
}
if (value.retentionDays < 0) {
return '保留天数不能小于 0'
}
if (value.maxBackups < 0) {
return '最大保留份数不能小于 0'
}
if (isFileBackupTask(value.type) && !value.sourcePath.trim()) {
return '请输入源路径'
}
if (isSQLiteBackupTask(value.type) && !value.dbPath.trim()) {
return '请输入 SQLite 数据库路径'
}
if (isDatabaseBackupTask(value.type)) {
if (!value.dbHost.trim()) {
return '请输入数据库主机'
}
if (!value.dbPort || value.dbPort <= 0) {
return '请输入正确的数据库端口'
}
if (!value.dbUser.trim()) {
return '请输入数据库用户名'
}
if (!initialValue?.maskedFields?.includes('dbPassword') && !value.dbPassword.trim()) {
return '请输入数据库密码'
}
if (!value.dbName.trim()) {
return '请输入数据库名称'
}
}
return ''
}
async function handleSubmit() {
const nextValue: BackupTaskPayload = {
...draft,
excludePatterns: excludePatternsText
.split('\n')
.map((item) => item.trim())
.filter(Boolean),
}
const validationError = validate(nextValue)
if (validationError) {
setError(validationError)
return
}
setError('')
await onSubmit(nextValue, initialValue?.id)
}
function renderBasicStep() {
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Text></Typography.Text>
<Input value={draft.name} placeholder="例如:生产站点每日备份" onChange={(value) => updateDraft({ name: value })} />
</div>
<div>
<Typography.Text></Typography.Text>
<Select value={draft.type} options={backupTaskTypeOptions as unknown as { label: string; value: string }[]} onChange={(value) => updateTaskType(value as BackupTaskType)} />
</div>
<div>
<Typography.Text>Cron </Typography.Text>
<CronInput value={draft.cronExpr} onChange={(value) => updateDraft({ cronExpr: value })} />
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
</Typography.Paragraph>
</div>
<Space align="center" size="medium">
<Typography.Text></Typography.Text>
<Switch checked={draft.enabled} onChange={(checked) => updateDraft({ enabled: checked })} />
</Space>
</Space>
)
}
function renderSourceStep() {
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{isFileBackupTask(draft.type) ? (
<>
<div>
<Typography.Text></Typography.Text>
<Input value={draft.sourcePath} placeholder="例如:/var/www/html" onChange={(value) => updateDraft({ sourcePath: value })} />
</div>
<div>
<Typography.Text></Typography.Text>
<Input.TextArea
value={excludePatternsText}
placeholder="每行一条例如node_modules\n*.log"
autoSize={{ minRows: 4, maxRows: 8 }}
onChange={(value) => setExcludePatternsText(value)}
/>
</div>
</>
) : null}
{isSQLiteBackupTask(draft.type) ? (
<div>
<Typography.Text>SQLite </Typography.Text>
<Input value={draft.dbPath} placeholder="例如:/data/app.db" onChange={(value) => updateDraft({ dbPath: value })} />
</div>
) : null}
{isDatabaseBackupTask(draft.type) ? (
<>
<div>
<Typography.Text></Typography.Text>
<Input value={draft.dbHost} placeholder="例如127.0.0.1" onChange={(value) => updateDraft({ dbHost: value })} />
</div>
<div>
<Typography.Text></Typography.Text>
<InputNumber style={{ width: '100%' }} value={draft.dbPort} min={1} onChange={(value) => updateDraft({ dbPort: Number(value ?? 0) })} />
</div>
<div>
<Typography.Text></Typography.Text>
<Input value={draft.dbUser} placeholder="例如backup" onChange={(value) => updateDraft({ dbUser: value })} />
</div>
<div>
<Typography.Text></Typography.Text>
<Input.Password value={draft.dbPassword} placeholder={initialValue?.maskedFields?.includes('dbPassword') ? '留空表示保持原密码' : '请输入数据库密码'} onChange={(value) => updateDraft({ dbPassword: value })} />
</div>
<div>
<Typography.Text></Typography.Text>
<Input value={draft.dbName} placeholder="例如app_prod" onChange={(value) => updateDraft({ dbName: value })} />
</div>
</>
) : null}
</Space>
)
}
function renderPolicyStep() {
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Text></Typography.Text>
<Select value={draft.storageTargetId || undefined} placeholder="请选择存储目标" options={storageTargetOptions} onChange={(value) => updateDraft({ storageTargetId: Number(value) })} />
</div>
<div>
<Typography.Text></Typography.Text>
<Select value={draft.compression} options={backupCompressionOptions as unknown as { label: string; value: string }[]} onChange={(value) => updateDraft({ compression: value as BackupTaskPayload['compression'] })} />
</div>
<div>
<Typography.Text></Typography.Text>
<InputNumber style={{ width: '100%' }} value={draft.retentionDays} min={0} onChange={(value) => updateDraft({ retentionDays: Number(value ?? 0) })} />
</div>
<div>
<Typography.Text></Typography.Text>
<InputNumber style={{ width: '100%' }} value={draft.maxBackups} min={0} onChange={(value) => updateDraft({ maxBackups: Number(value ?? 0) })} />
</div>
<Space align="center" size="medium">
<Typography.Text></Typography.Text>
<Switch checked={draft.encrypt} onChange={(checked) => updateDraft({ encrypt: checked })} />
</Space>
</Space>
)
}
return (
<Drawer
width={640}
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="配置数据库或文件的自动备份任务,系统将按策略执行并自动清理过期份数。" />}
<Steps current={currentStep} size="small">
<Steps.Step title="基础信息" />
<Steps.Step title="源配置" />
<Steps.Step title="存储与策略" />
</Steps>
<Divider style={{ margin: 0 }} />
{currentStep === 0 ? renderBasicStep() : null}
{currentStep === 1 ? renderSourceStep() : null}
{currentStep === 2 ? renderPolicyStep() : null}
<Space>
<Button disabled={currentStep === 0} onClick={() => setCurrentStep((value) => Math.max(0, value - 1))}>
</Button>
{currentStep < 2 ? (
<Button type="outline" onClick={() => setCurrentStep((value) => Math.min(2, value + 1))}>
</Button>
) : (
<Button type="primary" loading={loading} onClick={handleSubmit}>
</Button>
)}
</Space>
</Space>
</Drawer>
)
}

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import {
getBackupTaskStatusColor,
getBackupTaskStatusLabel,
getBackupTaskTypeLabel,
getDefaultPort,
isDatabaseBackupTask,
isFileBackupTask,
isSQLiteBackupTask,
} from './field-config'
describe('backup task field config', () => {
it('returns readable task labels', () => {
expect(getBackupTaskTypeLabel('file')).toBe('文件目录')
expect(getBackupTaskTypeLabel('postgresql')).toBe('PostgreSQL')
})
it('classifies task types correctly', () => {
expect(isFileBackupTask('file')).toBe(true)
expect(isSQLiteBackupTask('sqlite')).toBe(true)
expect(isDatabaseBackupTask('mysql')).toBe(true)
expect(isDatabaseBackupTask('postgresql')).toBe(true)
expect(isDatabaseBackupTask('file')).toBe(false)
})
it('returns expected status meta and default ports', () => {
expect(getBackupTaskStatusLabel('success')).toBe('成功')
expect(getBackupTaskStatusColor('failed')).toBe('red')
expect(getDefaultPort('mysql')).toBe(3306)
expect(getDefaultPort('postgresql')).toBe(5432)
})
})

View File

@@ -0,0 +1,83 @@
import type { BackupCompression, BackupTaskStatus, BackupTaskType } from '../../types/backup-tasks'
export const backupTaskTypeOptions = [
{ label: '文件目录', value: 'file' },
{ label: 'MySQL', value: 'mysql' },
{ label: 'SQLite', value: 'sqlite' },
{ label: 'PostgreSQL', value: 'postgresql' },
] as const
export const backupCompressionOptions = [
{ label: 'Gzip 压缩', value: 'gzip' },
{ label: '不压缩', value: 'none' },
] as const
export function getBackupTaskTypeLabel(type: BackupTaskType) {
switch (type) {
case 'file':
return '文件目录'
case 'mysql':
return 'MySQL'
case 'sqlite':
return 'SQLite'
case 'postgresql':
return 'PostgreSQL'
default:
return type
}
}
export function getBackupTaskStatusLabel(status: BackupTaskStatus) {
switch (status) {
case 'idle':
return '空闲'
case 'running':
return '执行中'
case 'success':
return '成功'
case 'failed':
return '失败'
default:
return status
}
}
export function getBackupTaskStatusColor(status: BackupTaskStatus) {
switch (status) {
case 'success':
return 'green'
case 'failed':
return 'red'
case 'running':
return 'arcoblue'
default:
return 'gray'
}
}
export function isFileBackupTask(type: BackupTaskType) {
return type === 'file'
}
export function isSQLiteBackupTask(type: BackupTaskType) {
return type === 'sqlite'
}
export function isDatabaseBackupTask(type: BackupTaskType) {
return type === 'mysql' || type === 'postgresql'
}
export function getDefaultPort(type: BackupTaskType) {
switch (type) {
case 'mysql':
return 3306
case 'postgresql':
return 5432
default:
return 0
}
}
export function getCompressionLabel(compression: BackupCompression) {
return compression === 'gzip' ? 'Gzip' : '无'
}

View File

@@ -0,0 +1,184 @@
import { Alert, Button, Drawer, Input, InputNumber, Select, Space, Switch, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
import type { NotificationDetail, NotificationPayload, NotificationType } from '../../types/notifications'
import { getNotificationFieldConfigs, getNotificationTypeLabel, notificationTypeOptions } from './field-config'
interface NotificationFormDrawerProps {
visible: boolean
loading: boolean
testing: boolean
initialValue: NotificationDetail | null
onCancel: () => void
onSubmit: (value: NotificationPayload, notificationId?: number) => Promise<void>
onTest: (value: NotificationPayload, notificationId?: number) => Promise<void>
}
function createEmptyDraft(): NotificationPayload {
return {
name: '',
type: 'webhook',
enabled: true,
onSuccess: false,
onFailure: true,
config: {},
}
}
export function NotificationFormDrawer({ visible, loading, testing, initialValue, onCancel, onSubmit, onTest }: NotificationFormDrawerProps) {
const [draft, setDraft] = useState<NotificationPayload>(createEmptyDraft())
const [error, setError] = useState('')
useEffect(() => {
if (!visible) {
return
}
if (!initialValue) {
setDraft(createEmptyDraft())
setError('')
return
}
setDraft({
name: initialValue.name,
type: initialValue.type,
enabled: initialValue.enabled,
onSuccess: initialValue.onSuccess,
onFailure: initialValue.onFailure,
config: { ...initialValue.config },
})
setError('')
}, [initialValue, visible])
const fieldConfigs = useMemo(() => getNotificationFieldConfigs(draft.type), [draft.type])
function updateDraft(patch: Partial<NotificationPayload>) {
setDraft((current) => ({ ...current, ...patch }))
}
function updateConfig(key: string, value: string | number) {
setDraft((current) => ({
...current,
config: {
...current.config,
[key]: value,
},
}))
}
function validate(value: NotificationPayload) {
if (!value.name.trim()) {
return '请输入通知名称'
}
for (const field of fieldConfigs) {
if (!field.required) {
continue
}
const currentValue = value.config[field.key]
if (typeof currentValue === 'number' && currentValue > 0) {
continue
}
if (typeof currentValue === 'string' && currentValue.trim()) {
continue
}
if (initialValue?.maskedFields?.includes(field.key) && (currentValue === '' || currentValue === undefined)) {
continue
}
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('')
await onTest(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} /> : null}
<div>
<Typography.Text></Typography.Text>
<Input value={draft.name} placeholder="例如:生产故障通知" onChange={(value) => updateDraft({ name: value })} />
</div>
<div>
<Typography.Text></Typography.Text>
<Select value={draft.type} options={notificationTypeOptions as unknown as { label: string; value: string }[]} onChange={(value) => updateDraft({ type: value as NotificationType, config: {} })} />
</div>
<Space align="center" size="medium">
<Typography.Text></Typography.Text>
<Switch checked={draft.enabled} onChange={(checked) => updateDraft({ enabled: checked })} />
</Space>
<Space align="center" size="medium">
<Typography.Text></Typography.Text>
<Switch checked={draft.onSuccess} onChange={(checked) => updateDraft({ onSuccess: checked })} />
</Space>
<Space align="center" size="medium">
<Typography.Text></Typography.Text>
<Switch checked={draft.onFailure} onChange={(checked) => updateDraft({ onFailure: checked })} />
</Space>
<div>
<Typography.Title heading={6} style={{ marginTop: 0 }}>
{getNotificationTypeLabel(draft.type)}
</Typography.Title>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{fieldConfigs.map((field) => {
const currentValue = draft.config[field.key]
const normalizedValue = typeof currentValue === 'number' || typeof currentValue === 'string' ? currentValue : field.type === 'number' ? 0 : ''
return (
<div key={field.key}>
<Typography.Text>
{field.label}
{field.required ? ' *' : ''}
</Typography.Text>
{field.type === 'password' ? (
<Input.Password value={String(normalizedValue)} placeholder={field.placeholder} onChange={(value) => updateConfig(field.key, value)} />
) : field.type === 'number' ? (
<InputNumber style={{ width: '100%' }} value={Number(normalizedValue)} min={0} onChange={(value) => updateConfig(field.key, Number(value ?? 0))} />
) : field.type === 'textarea' ? (
<Input.TextArea value={String(normalizedValue)} placeholder={field.placeholder} onChange={(value) => updateConfig(field.key, value)} />
) : (
<Input value={String(normalizedValue)} placeholder={field.placeholder} onChange={(value) => updateConfig(field.key, value)} />
)}
{field.description ? (
<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>
<Button type="primary" loading={loading} onClick={handleSubmit}>
</Button>
</Space>
</Space>
</Drawer>
)
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { getNotificationFieldConfigs, getNotificationTypeLabel } from './field-config'
describe('notification field config', () => {
it('returns readable type labels', () => {
expect(getNotificationTypeLabel('email')).toBe('Email')
expect(getNotificationTypeLabel('telegram')).toBe('Telegram')
})
it('returns required fields for each notification type', () => {
const emailFields = getNotificationFieldConfigs('email')
const webhookFields = getNotificationFieldConfigs('webhook')
const telegramFields = getNotificationFieldConfigs('telegram')
expect(emailFields.some((field) => field.key === 'host' && field.required)).toBe(true)
expect(webhookFields.some((field) => field.key === 'url' && field.required)).toBe(true)
expect(telegramFields.some((field) => field.key === 'botToken' && field.required)).toBe(true)
})
})

View File

@@ -0,0 +1,43 @@
import type { NotificationFieldConfig, NotificationType } from '../../types/notifications'
const FIELD_CONFIG_MAP: Record<NotificationType, NotificationFieldConfig[]> = {
email: [
{ key: 'host', label: 'SMTP Host', type: 'input', required: true, placeholder: 'smtp.example.com' },
{ key: 'port', label: 'SMTP Port', type: 'number', required: true, placeholder: '587' },
{ key: 'username', label: '用户名', type: 'input', placeholder: '可选' },
{ key: 'password', label: '密码', type: 'password', placeholder: '留空表示保持原密码', sensitive: true },
{ key: 'from', label: '发件人', type: 'input', required: true, placeholder: 'backupx@example.com' },
{ key: 'to', label: '收件人', type: 'input', required: true, placeholder: 'ops@example.com,dev@example.com' },
],
webhook: [
{ key: 'url', label: 'Webhook URL', type: 'input', required: true, placeholder: 'https://hooks.example.com/backupx' },
{ key: 'secret', label: '共享密钥', type: 'password', placeholder: '可选', sensitive: true },
],
telegram: [
{ key: 'botToken', label: 'Bot Token', type: 'password', required: true, placeholder: '123456:ABC', sensitive: true },
{ key: 'chatId', label: 'Chat ID', type: 'input', required: true, placeholder: '-100xxxxxxxxxx' },
],
}
export const notificationTypeOptions = [
{ label: 'Email', value: 'email' },
{ label: 'Webhook', value: 'webhook' },
{ label: 'Telegram', value: 'telegram' },
] as const
export function getNotificationTypeLabel(type: NotificationType) {
switch (type) {
case 'email':
return 'Email'
case 'webhook':
return 'Webhook'
case 'telegram':
return 'Telegram'
default:
return type
}
}
export function getNotificationFieldConfigs(type: NotificationType) {
return FIELD_CONFIG_MAP[type]
}

View File

@@ -0,0 +1,14 @@
import { Card } from '@arco-design/web-react';
import type { PropsWithChildren, ReactNode } from 'react';
interface PageCardProps extends PropsWithChildren {
title: ReactNode;
}
export function PageCard({ title, children }: PageCardProps) {
return (
<Card className="page-card" title={title} bordered={false}>
{children}
</Card>
);
}

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

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

View 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