mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-20 23:59:31 +08:00
first commit
This commit is contained in:
27
web/src/components/AuthBootstrap.tsx
Normal file
27
web/src/components/AuthBootstrap.tsx
Normal 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}</>
|
||||
}
|
||||
197
web/src/components/CronInput/CronInput.tsx
Normal file
197
web/src/components/CronInput/CronInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2
web/src/components/CronInput/index.ts
Normal file
2
web/src/components/CronInput/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CronInput } from './CronInput'
|
||||
export type { CronInputProps } from './CronInput'
|
||||
16
web/src/components/FullPageLoading.tsx
Normal file
16
web/src/components/FullPageLoading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
web/src/components/auth-guard.test.tsx
Normal file
58
web/src/components/auth-guard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
25
web/src/components/auth-guard.tsx
Normal file
25
web/src/components/auth-guard.tsx
Normal 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}</>;
|
||||
}
|
||||
232
web/src/components/backup-records/BackupRecordLogDrawer.tsx
Normal file
232
web/src/components/backup-records/BackupRecordLogDrawer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
web/src/components/backup-tasks/BackupTaskDetailDrawer.tsx
Normal file
63
web/src/components/backup-tasks/BackupTaskDetailDrawer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
325
web/src/components/backup-tasks/BackupTaskFormDrawer.tsx
Normal file
325
web/src/components/backup-tasks/BackupTaskFormDrawer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
web/src/components/backup-tasks/field-config.test.ts
Normal file
32
web/src/components/backup-tasks/field-config.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
83
web/src/components/backup-tasks/field-config.ts
Normal file
83
web/src/components/backup-tasks/field-config.ts
Normal 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' : '无'
|
||||
}
|
||||
184
web/src/components/notifications/NotificationFormDrawer.tsx
Normal file
184
web/src/components/notifications/NotificationFormDrawer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
web/src/components/notifications/field-config.test.ts
Normal file
19
web/src/components/notifications/field-config.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
43
web/src/components/notifications/field-config.ts
Normal file
43
web/src/components/notifications/field-config.ts
Normal 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]
|
||||
}
|
||||
14
web/src/components/page-card.tsx
Normal file
14
web/src/components/page-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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