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,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' : '无'
}