mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-05 17:59:34 +08:00
first commit
This commit is contained in:
189
web/src/pages/backup-records/BackupRecordsPage.tsx
Normal file
189
web/src/pages/backup-records/BackupRecordsPage.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { Button, Card, Empty, Message, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { BackupRecordLogDrawer } from '../../components/backup-records/BackupRecordLogDrawer'
|
||||
import { listBackupRecords } from '../../services/backup-records'
|
||||
import { listBackupTasks } from '../../services/backup-tasks'
|
||||
import type { BackupRecordStatus, BackupRecordSummary } from '../../types/backup-records'
|
||||
import type { BackupTaskSummary } from '../../types/backup-tasks'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatBytes, formatDateTime, formatDuration } from '../../utils/format'
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '执行中', value: 'running' },
|
||||
{ label: '成功', value: 'success' },
|
||||
{ label: '失败', value: 'failed' },
|
||||
]
|
||||
|
||||
function getRecordStatusColor(status: BackupRecordStatus) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'green'
|
||||
case 'failed':
|
||||
return 'red'
|
||||
default:
|
||||
return 'arcoblue'
|
||||
}
|
||||
}
|
||||
|
||||
export function BackupRecordsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [records, setRecords] = useState<BackupRecordSummary[]>([])
|
||||
const [tasks, setTasks] = useState<BackupTaskSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const selectedTaskId = Number(searchParams.get('taskId') ?? 0) || undefined
|
||||
const selectedRecordId = Number(searchParams.get('recordId') ?? 0) || undefined
|
||||
const selectedStatus = (searchParams.get('status') ?? '') as BackupRecordStatus | ''
|
||||
|
||||
const taskOptions = useMemo(
|
||||
() => [{ label: '全部任务', value: 0 }, ...tasks.map((item) => ({ label: item.name, value: item.id }))],
|
||||
[tasks],
|
||||
)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [recordList, taskList] = await Promise.all([
|
||||
listBackupRecords({ taskId: selectedTaskId, status: selectedStatus }),
|
||||
listBackupTasks(),
|
||||
])
|
||||
setRecords(recordList)
|
||||
setTasks(taskList)
|
||||
setError('')
|
||||
} catch (loadError) {
|
||||
setError(resolveErrorMessage(loadError, '加载备份记录失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [selectedStatus, selectedTaskId])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
function updateSearchParam(key: 'taskId' | 'status' | 'recordId', value?: string) {
|
||||
const nextParams = new URLSearchParams(searchParams)
|
||||
if (!value || value === '0') {
|
||||
nextParams.delete(key)
|
||||
} else {
|
||||
nextParams.set(key, value)
|
||||
}
|
||||
setSearchParams(nextParams, { replace: true })
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '任务 / 状态',
|
||||
dataIndex: 'taskName',
|
||||
render: (_: unknown, record: BackupRecordSummary) => {
|
||||
const statusLabel = record.status === 'success' ? '成功' : record.status === 'failed' ? '失败' : record.status === 'running' ? '执行中' : record.status
|
||||
return (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text bold>{record.taskName}</Typography.Text>
|
||||
<Space>
|
||||
{statusLabel ? <Tag color={getRecordStatusColor(record.status)} bordered>{statusLabel}</Tag> : <span style={{ color: 'var(--color-text-3)' }}>-</span>}
|
||||
{record.storageTargetName ? <Tag color="arcoblue" bordered>{record.storageTargetName}</Tag> : <span style={{ color: 'var(--color-text-3)' }}>-</span>}
|
||||
</Space>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '文件',
|
||||
dataIndex: 'fileName',
|
||||
render: (_: unknown, record: BackupRecordSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{record.fileName || '-'}</Typography.Text>
|
||||
<Typography.Text type="secondary">{formatBytes(record.fileSize)}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '开始 / 完成',
|
||||
dataIndex: 'startedAt',
|
||||
render: (_: unknown, record: BackupRecordSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{formatDateTime(record.startedAt)}</Typography.Text>
|
||||
<Typography.Text type="secondary">{formatDateTime(record.completedAt)}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'durationSeconds',
|
||||
render: (value: number) => formatDuration(value),
|
||||
},
|
||||
{
|
||||
title: '错误信息',
|
||||
dataIndex: 'errorMessage',
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'actions',
|
||||
width: 120,
|
||||
render: (_: unknown, record: BackupRecordSummary) => (
|
||||
<Button size="small" type="text" onClick={() => updateSearchParam('recordId', String(record.id))}>
|
||||
查看日志
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title heading={4}>备份记录</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
查看任务执行结果、筛选历史记录,并在详情中跟踪实时日志、下载或恢复产物。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Space wrap>
|
||||
<div>
|
||||
<Typography.Text>任务筛选</Typography.Text>
|
||||
<Select style={{ width: 240 }} value={selectedTaskId ?? 0} options={taskOptions} onChange={(value) => updateSearchParam('taskId', Number(value) > 0 ? String(value) : undefined)} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>状态筛选</Typography.Text>
|
||||
<Select style={{ width: 180 }} value={selectedStatus} options={statusOptions} onChange={(value) => updateSearchParam('status', value ? String(value) : undefined)} />
|
||||
</div>
|
||||
<Button type="outline" onClick={() => {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.delete('taskId')
|
||||
next.delete('status')
|
||||
setSearchParams(next, { replace: true })
|
||||
}}>
|
||||
重置筛选
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
|
||||
|
||||
<Card>
|
||||
{records.length === 0 && !loading ? (
|
||||
<Empty description="暂无备份记录" />
|
||||
) : (
|
||||
<Table rowKey="id" loading={loading} columns={columns} data={records} pagination={{ pageSize: 10 }} stripe noDataElement={<Empty description="暂无符合条件的备份记录" />} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<BackupRecordLogDrawer
|
||||
visible={Boolean(selectedRecordId)}
|
||||
recordId={selectedRecordId}
|
||||
onCancel={() => updateSearchParam('recordId', undefined)}
|
||||
onChanged={async () => {
|
||||
await loadData()
|
||||
if (selectedRecordId) {
|
||||
Message.success('备份记录已更新')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
248
web/src/pages/backup-tasks/BackupTasksPage.tsx
Normal file
248
web/src/pages/backup-tasks/BackupTasksPage.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { Button, Card, Empty, Message, PageHeader, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { BackupTaskDetailDrawer } from '../../components/backup-tasks/BackupTaskDetailDrawer'
|
||||
import { BackupTaskFormDrawer } from '../../components/backup-tasks/BackupTaskFormDrawer'
|
||||
import { getBackupTaskStatusColor, getBackupTaskStatusLabel, getBackupTaskTypeLabel } from '../../components/backup-tasks/field-config'
|
||||
import { createBackupTask, deleteBackupTask, getBackupTask, listBackupTasks, runBackupTask, toggleBackupTask, updateBackupTask } from '../../services/backup-tasks'
|
||||
import { listStorageTargets } from '../../services/storage-targets'
|
||||
import type { BackupTaskDetail, BackupTaskPayload, BackupTaskSummary } from '../../types/backup-tasks'
|
||||
import type { StorageTargetSummary } from '../../types/storage-targets'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
|
||||
export function BackupTasksPage() {
|
||||
const navigate = useNavigate()
|
||||
const [tasks, setTasks] = useState<BackupTaskSummary[]>([])
|
||||
const [storageTargets, setStorageTargets] = useState<StorageTargetSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
const [detailVisible, setDetailVisible] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState<BackupTaskDetail | null>(null)
|
||||
const [detailTask, setDetailTask] = useState<BackupTaskDetail | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const enabledStorageTargets = useMemo(() => storageTargets.filter((item) => item.enabled), [storageTargets])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [taskList, targetList] = await Promise.all([listBackupTasks(), listStorageTargets()])
|
||||
setTasks(taskList)
|
||||
setStorageTargets(targetList)
|
||||
setError('')
|
||||
} catch (loadError) {
|
||||
setError(resolveErrorMessage(loadError, '加载备份任务失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
async function openEdit(id: number) {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const detail = await getBackupTask(id)
|
||||
setEditingTask(detail)
|
||||
setDrawerVisible(true)
|
||||
} catch (loadError) {
|
||||
Message.error(resolveErrorMessage(loadError, '加载任务详情失败'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(id: number) {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const detail = await getBackupTask(id)
|
||||
setDetailTask(detail)
|
||||
setDetailVisible(true)
|
||||
} catch (loadError) {
|
||||
Message.error(resolveErrorMessage(loadError, '加载任务详情失败'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(value: BackupTaskPayload, taskId?: number) {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (taskId) {
|
||||
await updateBackupTask(taskId, value)
|
||||
Message.success('备份任务已更新')
|
||||
} else {
|
||||
await createBackupTask(value)
|
||||
Message.success('备份任务已创建')
|
||||
}
|
||||
setDrawerVisible(false)
|
||||
setEditingTask(null)
|
||||
await loadData()
|
||||
} catch (submitError) {
|
||||
Message.error(resolveErrorMessage(submitError, '保存备份任务失败'))
|
||||
throw submitError
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(task: BackupTaskSummary) {
|
||||
try {
|
||||
await toggleBackupTask(task.id, { enabled: !task.enabled })
|
||||
Message.success(task.enabled ? '任务已停用' : '任务已启用')
|
||||
await loadData()
|
||||
} catch (toggleError) {
|
||||
Message.error(resolveErrorMessage(toggleError, '切换任务状态失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRun(task: BackupTaskSummary) {
|
||||
try {
|
||||
const record = await runBackupTask(task.id)
|
||||
Message.success('已触发备份任务,正在打开执行日志')
|
||||
navigate(`/backup/records?taskId=${task.id}&recordId=${record.id}`)
|
||||
} catch (runError) {
|
||||
Message.error(resolveErrorMessage(runError, '触发备份任务失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(task: BackupTaskSummary) {
|
||||
if (!window.confirm(`确定删除任务“${task.name}”吗?`)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteBackupTask(task.id)
|
||||
Message.success('备份任务已删除')
|
||||
await loadData()
|
||||
} catch (deleteError) {
|
||||
Message.error(resolveErrorMessage(deleteError, '删除备份任务失败'))
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '任务名称',
|
||||
dataIndex: 'name',
|
||||
render: (_: unknown, record: BackupTaskSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text bold>{record.name}</Typography.Text>
|
||||
<Space>
|
||||
{getBackupTaskTypeLabel(record.type) && <Tag color="arcoblue" bordered>{getBackupTaskTypeLabel(record.type)}</Tag>}
|
||||
{record.enabled !== undefined && (
|
||||
<Tag color={record.enabled ? 'green' : 'gray'} bordered>{record.enabled ? '已启用' : '已停用'}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '调度',
|
||||
dataIndex: 'cronExpr',
|
||||
render: (value: string) => value || '仅手动执行',
|
||||
},
|
||||
{
|
||||
title: '存储目标',
|
||||
dataIndex: 'storageTargetName',
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '策略',
|
||||
dataIndex: 'retentionDays',
|
||||
render: (_: unknown, record: BackupTaskSummary) => `${record.retentionDays} 天 / ${record.maxBackups} 份`,
|
||||
},
|
||||
{
|
||||
title: '最近状态',
|
||||
render: (value: BackupTaskSummary['lastStatus']) => {
|
||||
const label = getBackupTaskStatusLabel(value)
|
||||
return label ? <Tag color={getBackupTaskStatusColor(value)} bordered>{label}</Tag> : <span style={{ color: 'var(--color-text-3)' }}>-</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '最近执行',
|
||||
dataIndex: 'lastRunAt',
|
||||
render: (value?: string) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'actions',
|
||||
width: 280,
|
||||
render: (_: unknown, record: BackupTaskSummary) => (
|
||||
<Space wrap size="mini">
|
||||
<Button size="small" type="text" onClick={() => void openDetail(record.id)}>
|
||||
详情
|
||||
</Button>
|
||||
<Button size="small" type="text" onClick={() => void openEdit(record.id)} loading={submitting && editingTask?.id === record.id}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="small" type="text" status="success" onClick={() => void handleRun(record)}>
|
||||
立即执行
|
||||
</Button>
|
||||
<Button size="small" type="text" onClick={() => void handleToggle(record)}>
|
||||
{record.enabled ? '停用' : '启用'}
|
||||
</Button>
|
||||
<Button size="small" type="text" status="danger" onClick={() => void handleDelete(record)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<PageHeader
|
||||
style={{ paddingBottom: 16 }}
|
||||
title="备份任务"
|
||||
subTitle="管理文件目录、MySQL、SQLite 与 PostgreSQL 的备份计划,并支持立即执行"
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={enabledStorageTargets.length === 0}
|
||||
onClick={() => {
|
||||
setEditingTask(null)
|
||||
setDrawerVisible(true)
|
||||
}}
|
||||
>
|
||||
新建任务
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
|
||||
{enabledStorageTargets.length === 0 ? (
|
||||
<Card>
|
||||
<Empty description="请先启用至少一个存储目标,再创建备份任务。" />
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<Table rowKey="id" loading={loading} columns={columns} data={tasks} pagination={{ pageSize: 10 }} stripe noDataElement={<Empty description="暂无备份任务,请先点击右上角创建任务" />} />
|
||||
</Card>
|
||||
|
||||
<BackupTaskFormDrawer
|
||||
visible={drawerVisible}
|
||||
loading={submitting}
|
||||
initialValue={editingTask}
|
||||
storageTargets={enabledStorageTargets}
|
||||
onCancel={() => {
|
||||
setDrawerVisible(false)
|
||||
setEditingTask(null)
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
<BackupTaskDetailDrawer
|
||||
visible={detailVisible}
|
||||
task={detailTask}
|
||||
onCancel={() => {
|
||||
setDetailVisible(false)
|
||||
setDetailTask(null)
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
219
web/src/pages/dashboard/DashboardPage.tsx
Normal file
219
web/src/pages/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { Avatar, Card, Empty, Grid, PageHeader, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconCheckCircle, IconHistory, IconSave, IconStorage } from '@arco-design/web-react/icon'
|
||||
import ReactEChartsCore from 'echarts-for-react/lib/core'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { LineChart, PieChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { fetchDashboardStats, fetchDashboardTimeline } from '../../services/dashboard'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import type { BackupTimelinePoint, DashboardStats } from '../../types/dashboard'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatBytes, formatDateTime, formatPercent } from '../../utils/format'
|
||||
|
||||
echarts.use([LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
|
||||
|
||||
const { Row, Col } = Grid
|
||||
|
||||
export function DashboardPage() {
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [timeline, setTimeline] = useState<BackupTimelinePoint[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void (async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [statsResult, timelineResult] = await Promise.all([fetchDashboardStats(), fetchDashboardTimeline(30)])
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
setStats(statsResult)
|
||||
setTimeline(timelineResult || [])
|
||||
setError('')
|
||||
} catch (loadError) {
|
||||
if (active) {
|
||||
setError(resolveErrorMessage(loadError, '加载仪表盘失败'))
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cards = useMemo(
|
||||
() => [
|
||||
{ label: '备份任务', value: stats?.totalTasks ?? 0, helper: `${stats?.enabledTasks ?? 0} 个已启用`, icon: <IconStorage />, color: 'var(--color-primary-6)', bg: 'var(--color-primary-1)' },
|
||||
{ label: '成功率', value: formatPercent(stats?.successRate), helper: '最近 30 天', icon: <IconCheckCircle />, color: 'var(--color-success-6)', bg: 'var(--color-success-1)' },
|
||||
{ label: '总备份量', value: formatBytes(stats?.totalBackupBytes), helper: '历史累计', icon: <IconSave />, color: 'var(--color-purple-6)', bg: 'var(--color-purple-1)' },
|
||||
{ label: '最近备份', value: stats?.totalRecords ?? 0, helper: formatDateTime(stats?.lastBackupAt), icon: <IconHistory />, color: 'var(--color-warning-6)', bg: 'var(--color-warning-1)' },
|
||||
],
|
||||
[stats],
|
||||
)
|
||||
|
||||
const timelineChartOption = useMemo(() => ({
|
||||
tooltip: { trigger: 'axis' as const },
|
||||
legend: { data: ['成功', '失败'], bottom: 0 },
|
||||
grid: { left: 40, right: 20, top: 40, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: timeline.map((p) => p.date),
|
||||
axisLabel: { rotate: 45, fontSize: 11, color: 'var(--color-text-3)' },
|
||||
axisLine: { lineStyle: { color: 'var(--color-border-2)' } },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value' as const,
|
||||
minInterval: 1,
|
||||
axisLabel: { color: 'var(--color-text-3)' },
|
||||
splitLine: { lineStyle: { type: 'dashed', color: 'var(--color-border-2)' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '成功',
|
||||
type: 'line' as const,
|
||||
smooth: true,
|
||||
data: timeline.map((p) => p.success),
|
||||
itemStyle: { color: 'var(--color-primary-6)' },
|
||||
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(52,145,250,0.25)' },
|
||||
{ offset: 1, color: 'rgba(52,145,250,0.02)' },
|
||||
]) },
|
||||
symbolSize: 6,
|
||||
},
|
||||
{
|
||||
name: '失败',
|
||||
type: 'line' as const,
|
||||
smooth: true,
|
||||
data: timeline.map((p) => p.failed),
|
||||
itemStyle: { color: 'var(--color-danger-light-4)' },
|
||||
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(245,63,63,0.15)' },
|
||||
{ offset: 1, color: 'rgba(245,63,63,0.01)' },
|
||||
]) },
|
||||
symbolSize: 6,
|
||||
},
|
||||
],
|
||||
}), [timeline])
|
||||
|
||||
const storageChartOption = useMemo(() => {
|
||||
const data = (stats?.storageUsage ?? []).map((s) => ({
|
||||
name: s.targetName || '未命名',
|
||||
value: s.totalSize,
|
||||
}))
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item' as const,
|
||||
formatter: (params: { name: string; value: number; percent: number }) =>
|
||||
`${params.name}: ${formatBytes(params.value)} (${params.percent}%)`,
|
||||
},
|
||||
legend: { bottom: 0, type: 'scroll' as const },
|
||||
series: [
|
||||
{
|
||||
type: 'pie' as const,
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: { borderRadius: 6, borderColor: 'var(--color-bg-2)', borderWidth: 2 },
|
||||
label: { show: false },
|
||||
emphasis: { label: { show: true, fontSize: 13, fontWeight: 'bold' } },
|
||||
data,
|
||||
color: ['#165DFF', '#14C9C9', '#FADC19', '#FF7D00', '#F53F3F', '#722ED1'],
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [stats])
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<PageHeader
|
||||
style={{ paddingBottom: 16 }}
|
||||
title={`欢迎回来,${user?.displayName ?? user?.username ?? '管理员'}`}
|
||||
subTitle="快速查看备份执行健康度、最近记录和各存储目标使用量"
|
||||
>
|
||||
{error ? <Typography.Text type="error">{error}</Typography.Text> : null}
|
||||
</PageHeader>
|
||||
|
||||
<Row gutter={16}>
|
||||
{cards.map((card) => (
|
||||
<Col key={card.label} span={6}>
|
||||
<Card loading={loading} hoverable>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Avatar shape="square" size={54} style={{ borderRadius: 12, backgroundColor: card.bg, color: card.color }}>
|
||||
{card.icon}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>{card.label}</Typography.Text>
|
||||
<Typography.Title heading={4} style={{ margin: '4px 0 2px' }}>
|
||||
{card.value}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{card.helper}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={14}>
|
||||
<Card loading={loading} title="最近 30 天备份趋势">
|
||||
{timeline.length > 0 ? (
|
||||
<ReactEChartsCore echarts={echarts} option={timelineChartOption} style={{ height: 300 }} />
|
||||
) : (
|
||||
<div style={{ height: 300, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography.Text type="secondary">暂无数据</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Card loading={loading} title="存储使用量分布">
|
||||
{(stats?.storageUsage ?? []).length > 0 ? (
|
||||
<ReactEChartsCore echarts={echarts} option={storageChartOption} style={{ height: 300 }} />
|
||||
) : (
|
||||
<div style={{ height: 300, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography.Text type="secondary">暂无存储数据</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card loading={loading} title="最近备份记录">
|
||||
<Table
|
||||
noDataElement={<Empty description="暂无近期运行记录" />}
|
||||
rowKey="id"
|
||||
columns={[
|
||||
{ title: '任务', dataIndex: 'taskName' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (value: string) => {
|
||||
const label = value === 'success' ? '成功' : value === 'failed' ? '失败' : value === 'running' ? '执行中' : value
|
||||
return label ? (
|
||||
<Tag color={value === 'success' ? 'green' : value === 'failed' ? 'red' : 'arcoblue'} bordered>
|
||||
{label}
|
||||
</Tag>
|
||||
) : <span style={{ color: 'var(--color-text-3)' }}>-</span>
|
||||
},
|
||||
},
|
||||
{ title: '文件大小', dataIndex: 'fileSize', render: (value: number) => formatBytes(value) },
|
||||
{ title: '开始时间', dataIndex: 'startedAt', render: (value: string) => formatDateTime(value) },
|
||||
]}
|
||||
data={stats?.recentRecords ?? []}
|
||||
pagination={false}
|
||||
stripe
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
30
web/src/pages/dashboard/page.tsx
Normal file
30
web/src/pages/dashboard/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Grid, Statistic, Typography } from '@arco-design/web-react';
|
||||
|
||||
import { PageCard } from '../../components/page-card';
|
||||
|
||||
const cards = [
|
||||
{ label: '存储目标', value: 0 },
|
||||
{ label: '备份任务', value: 0 },
|
||||
{ label: '最近执行', value: 0 },
|
||||
];
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<PageCard title="平台概览">
|
||||
<Typography.Paragraph type="secondary">
|
||||
`platform-foundation` 阶段提供基础登录、导航与系统状态展示,后续模块将在此页面扩展统计与运行数据。
|
||||
</Typography.Paragraph>
|
||||
</PageCard>
|
||||
<Grid.Row gutter={16}>
|
||||
{cards.map((card) => (
|
||||
<Grid.Col key={card.label} xs={24} md={8}>
|
||||
<PageCard title={card.label}>
|
||||
<Statistic title={card.label} value={card.value} />
|
||||
</PageCard>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid.Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
web/src/pages/login/LoginPage.tsx
Normal file
201
web/src/pages/login/LoginPage.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Alert, Button, Card, Form, Input, Space, Typography, Message } from '@arco-design/web-react'
|
||||
import { IconCloud, IconLock, IconUser } from '@arco-design/web-react/icon'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { fetchSetupStatus } from '../../services/auth'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
interface SetupFormValues {
|
||||
username: string
|
||||
password: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
interface LoginFormValues {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
return error.response?.data?.message ?? '请求失败,请稍后重试'
|
||||
}
|
||||
return '请求失败,请稍后重试'
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const doLogin = useAuthStore((state) => state.login)
|
||||
const doSetup = useAuthStore((state) => state.setup)
|
||||
const [initialized, setInitialized] = useState<boolean | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (authStatus === 'authenticated') {
|
||||
navigate('/dashboard', { replace: true })
|
||||
}
|
||||
}, [authStatus, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await fetchSetupStatus()
|
||||
if (mounted) {
|
||||
setInitialized(result.initialized)
|
||||
}
|
||||
} catch {
|
||||
if (mounted) {
|
||||
setInitialized(true)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSetup = async (values: SetupFormValues) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await doSetup(values)
|
||||
Message.success('初始化完成,正在进入控制台')
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (error) {
|
||||
Message.error(resolveErrorMessage(error))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = async (values: LoginFormValues) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await doLogin(values)
|
||||
Message.success('登录成功')
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (error) {
|
||||
Message.error(resolveErrorMessage(error))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<div className="login-bg" />
|
||||
<div className="login-container">
|
||||
<div className="login-banner">
|
||||
{/* Background decorative circles for the banner */}
|
||||
<div style={{ position: 'absolute', width: 400, height: 400, borderRadius: '50%', background: 'rgba(255,255,255,0.05)', top: -100, right: -100 }} />
|
||||
<div style={{ position: 'absolute', width: 300, height: 300, borderRadius: '50%', background: 'rgba(255,255,255,0.05)', bottom: -50, left: -50 }} />
|
||||
|
||||
<div className="login-banner-inner">
|
||||
<svg width="320" height="320" viewBox="0 0 320 320" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ marginBottom: 16 }}>
|
||||
{/* Outer pulsing rings */}
|
||||
<circle cx="160" cy="160" r="120" fill="white" fillOpacity="0.05">
|
||||
<animate attributeName="r" values="115;125;115" dur="4s" repeatCount="indefinite"/>
|
||||
<animate attributeName="fill-opacity" values="0.03;0.08;0.03" dur="4s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="160" cy="160" r="80" fill="white" fillOpacity="0.1">
|
||||
<animate attributeName="r" values="75;85;75" dur="3s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="translate" values="0,0; 0,-8; 0,0" dur="5s" repeatCount="indefinite"/>
|
||||
{/* Layer 1 (Top) */}
|
||||
<path d="M120 120C120 111.163 137.909 104 160 104C182.091 104 200 111.163 200 120V144C200 152.837 182.091 160 160 160C137.909 160 120 152.837 120 144V120Z" fill="white" fillOpacity="0.95"/>
|
||||
<ellipse cx="160" cy="120" rx="40" ry="16" fill="white"/>
|
||||
|
||||
{/* Layer 2 (Middle) */}
|
||||
<path d="M120 152C120 143.163 137.909 136 160 136C182.091 136 200 143.163 200 152V176C200 184.837 182.091 192 160 192C137.909 192 120 184.837 120 176V152Z" fill="white" fillOpacity="0.75"/>
|
||||
<ellipse cx="160" cy="152" rx="40" ry="16" fill="white" fillOpacity="0.9"/>
|
||||
|
||||
{/* Layer 3 (Bottom) */}
|
||||
<path d="M120 184C120 175.163 137.909 168 160 168C182.091 168 200 175.163 200 184V208C200 216.837 182.091 224 160 224C137.909 224 120 216.837 120 208V184Z" fill="white" fillOpacity="0.5"/>
|
||||
<ellipse cx="160" cy="184" rx="40" ry="16" fill="white" fillOpacity="0.6"/>
|
||||
|
||||
{/* Glowing Dots Output - Animated */}
|
||||
<g fill="var(--color-primary-6, #165dff)">
|
||||
<circle cx="140" cy="120" r="4">
|
||||
<animate attributeName="opacity" values="0.3;1;0.3" dur="2s" begin="0s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="140" cy="152" r="4">
|
||||
<animate attributeName="opacity" values="0.3;1;0.3" dur="2s" begin="0.6s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="140" cy="184" r="4">
|
||||
<animate attributeName="opacity" values="0.3;1;0.3" dur="2s" begin="1.2s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
{/* Connecting Data Line */}
|
||||
<path d="M160 120V152V184" stroke="var(--color-primary-6, #165dff)" strokeWidth="2" strokeDasharray="4 4" opacity="0.6">
|
||||
<animate attributeName="stroke-dashoffset" from="16" to="0" dur="1s" repeatCount="indefinite" />
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<Typography.Title heading={2} style={{ color: 'white', marginTop: 0, marginBottom: 12, fontWeight: 700 }}>
|
||||
守护您的数据资产
|
||||
</Typography.Title>
|
||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.75)', fontSize: 16 }}>
|
||||
安全、可靠、高效的企业级服务器备份管理平台
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="login-form-wrapper">
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div style={{ paddingBottom: 8 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 36, height: 36, borderRadius: 10, background: 'linear-gradient(135deg, var(--color-primary-5) 0%, var(--color-primary-7) 100%)', marginRight: 12 }}>
|
||||
<IconCloud style={{ fontSize: 20, color: 'white' }} />
|
||||
</div>
|
||||
<Typography.Title heading={4} style={{ margin: 0, fontWeight: 700 }}>
|
||||
BackupX
|
||||
</Typography.Title>
|
||||
</div>
|
||||
<Typography.Title heading={3} style={{ marginTop: 0, marginBottom: 8, fontWeight: 600 }}>
|
||||
{initialized === false ? '系统初始化' : '欢迎回来'}
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 14 }}>
|
||||
{initialized === false ? '请设定首个管理员账户以启动系统。' : '请输入管理员账户信息登录控制台。'}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
{initialized === false ? (
|
||||
<Form<SetupFormValues> layout="vertical" onSubmit={handleSetup}>
|
||||
<Form.Item field="displayName" label="显示名称" rules={[{ required: true, minLength: 1 }]}>
|
||||
<Input placeholder="请输入显示名称" prefix={<IconUser />} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item field="username" label="用户名" rules={[{ required: true, minLength: 3 }]}>
|
||||
<Input placeholder="请输入管理员用户名" prefix={<IconUser />} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item field="password" label="密码" rules={[{ required: true, minLength: 8 }]}>
|
||||
<Input.Password placeholder="请输入至少 8 位密码" prefix={<IconLock />} size="large" />
|
||||
</Form.Item>
|
||||
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 8, height: 44, marginTop: 8 }}>
|
||||
初始化并登录
|
||||
</Button>
|
||||
</Form>
|
||||
) : (
|
||||
<Form<LoginFormValues> layout="vertical" onSubmit={handleLogin}>
|
||||
<Form.Item field="username" label="用户名" rules={[{ required: true, minLength: 3 }]}>
|
||||
<Input placeholder="请输入用户名" prefix={<IconUser />} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item field="password" label="密码" rules={[{ required: true, minLength: 8 }]}>
|
||||
<Input.Password placeholder="请输入密码" prefix={<IconLock />} size="large" />
|
||||
</Form.Item>
|
||||
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 8, height: 44, marginTop: 16 }}>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
web/src/pages/login/page.tsx
Normal file
79
web/src/pages/login/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Grid,
|
||||
Input,
|
||||
Space,
|
||||
Typography,
|
||||
} from '@arco-design/web-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
|
||||
interface LoginFormValue {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const status = useAuthStore((state) => state.status);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const redirectPath = useMemo(() => {
|
||||
const from = location.state as { from?: { pathname?: string } } | null;
|
||||
return from?.from?.pathname ?? '/';
|
||||
}, [location.state]);
|
||||
|
||||
async function handleSubmit(values: LoginFormValue) {
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
await login(values);
|
||||
navigate(redirectPath, { replace: true });
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '登录失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fullscreen-center login-page">
|
||||
<Grid.Row justify="center" style={{ width: '100%' }}>
|
||||
<Grid.Col xs={22} sm={16} md={12} lg={8} xl={6}>
|
||||
<Card bordered={false} className="login-card">
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title heading={3}>欢迎使用 BackupX</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
登录后可管理备份任务、存储目标与系统状态。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
{errorMessage ? <Alert type="error" content={errorMessage} /> : null}
|
||||
<Form<LoginFormValue> layout="vertical" onSubmit={handleSubmit}>
|
||||
<Form.Item field="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input autoComplete="username" placeholder="请输入管理员用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item field="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password autoComplete="current-password" placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
long
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={status === 'bootstrapping'}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
</Space>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid.Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
web/src/pages/nodes/NodesPage.tsx
Normal file
180
web/src/pages/nodes/NodesPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Table, Button, Space, Tag, Typography, PageHeader, Modal, Input, Message, Badge, Popconfirm, Card, Descriptions, Empty
|
||||
} from '@arco-design/web-react'
|
||||
import {
|
||||
IconPlus, IconDelete, IconDesktop, IconCloudDownload
|
||||
} from '@arco-design/web-react/icon'
|
||||
import type { NodeSummary } from '../../types/nodes'
|
||||
import { listNodes, createNode, deleteNode } from '../../services/nodes'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
export default function NodesPage() {
|
||||
const [nodes, setNodes] = useState<NodeSummary[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [createVisible, setCreateVisible] = useState(false)
|
||||
const [newNodeName, setNewNodeName] = useState('')
|
||||
const [newToken, setNewToken] = useState('')
|
||||
|
||||
const fetchNodes = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await listNodes()
|
||||
setNodes(data)
|
||||
} catch {
|
||||
Message.error('获取节点列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchNodes() }, [fetchNodes])
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newNodeName.trim()) {
|
||||
Message.warning('请输入节点名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await createNode(newNodeName.trim())
|
||||
setNewToken(result.token)
|
||||
Message.success('节点创建成功')
|
||||
fetchNodes()
|
||||
} catch {
|
||||
Message.error('创建节点失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteNode(id)
|
||||
Message.success('节点已删除')
|
||||
fetchNodes()
|
||||
} catch {
|
||||
Message.error('删除节点失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '节点名称',
|
||||
dataIndex: 'name',
|
||||
render: (name: string, record: NodeSummary) => (
|
||||
<Space>
|
||||
{record.isLocal ? <IconDesktop style={{ color: 'var(--color-primary-6)' }} /> : <IconCloudDownload />}
|
||||
<Text bold>{name}</Text>
|
||||
{record.isLocal && <Tag color="arcoblue" size="small" bordered>本机</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (status: string) => {
|
||||
if (status === 'online') return <Badge status="success" text="在线" />
|
||||
return <Badge status="default" text="离线" />
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '主机名',
|
||||
dataIndex: 'hostname',
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: 'IP 地址',
|
||||
dataIndex: 'ipAddress',
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '系统',
|
||||
dataIndex: 'os',
|
||||
width: 120,
|
||||
render: (_: string, record: NodeSummary) => {
|
||||
if (!record.os) return '-'
|
||||
return <Tag bordered>{record.os}/{record.arch}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Agent 版本',
|
||||
dataIndex: 'agentVersion',
|
||||
width: 100,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '最后活跃',
|
||||
dataIndex: 'lastSeen',
|
||||
width: 170,
|
||||
render: (v: string) => v ? new Date(v).toLocaleString('zh-CN') : '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_: unknown, record: NodeSummary) => {
|
||||
if (record.isLocal) return <Text type="secondary">-</Text>
|
||||
return (
|
||||
<Popconfirm title="确定删除该节点?" onOk={() => handleDelete(record.id)}>
|
||||
<Button type="text" status="danger" icon={<IconDelete />} size="small" />
|
||||
</Popconfirm>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: '0 4px' }}>
|
||||
<PageHeader
|
||||
title="节点管理"
|
||||
subTitle="管理集群中的服务器节点"
|
||||
extra={
|
||||
<Button type="primary" icon={<IconPlus />} onClick={() => { setCreateVisible(true); setNewToken(''); setNewNodeName('') }}>
|
||||
添加节点
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={nodes}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
noDataElement={<Empty description="暂无节点数据,系统将自动创建本机节点" />}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="添加远程节点"
|
||||
visible={createVisible}
|
||||
onCancel={() => setCreateVisible(false)}
|
||||
footer={newToken ? (
|
||||
<Button type="primary" onClick={() => setCreateVisible(false)}>完成</Button>
|
||||
) : undefined}
|
||||
onOk={handleCreate}
|
||||
okText="创建"
|
||||
>
|
||||
{!newToken ? (
|
||||
<Input
|
||||
placeholder="输入节点名称,如:生产服务器-A"
|
||||
value={newNodeName}
|
||||
onChange={setNewNodeName}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<Descriptions column={1} border data={[
|
||||
{ label: '节点名称', value: newNodeName },
|
||||
{ label: '认证令牌', value: <Text copyable style={{ wordBreak: 'break-all', fontSize: 12, fontFamily: 'monospace' }}>{newToken}</Text> },
|
||||
]} />
|
||||
<div style={{ marginTop: 12, padding: '8px 12px', background: 'var(--color-fill-2)', borderRadius: 6 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
请将此令牌配置到远程服务器的 Agent 启动参数中。令牌仅显示一次,请妥善保存。
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
186
web/src/pages/notifications/NotificationsPage.tsx
Normal file
186
web/src/pages/notifications/NotificationsPage.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Button, Card, Empty, Message, PageHeader, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { NotificationFormDrawer } from '../../components/notifications/NotificationFormDrawer'
|
||||
import { getNotificationTypeLabel } from '../../components/notifications/field-config'
|
||||
import { createNotification, deleteNotification, getNotification, listNotifications, testNotification, testSavedNotification, updateNotification } from '../../services/notifications'
|
||||
import type { NotificationDetail, NotificationPayload, NotificationSummary } from '../../types/notifications'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
|
||||
export function NotificationsPage() {
|
||||
const [items, setItems] = useState<NotificationSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
const [editingItem, setEditingItem] = useState<NotificationDetail | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await listNotifications()
|
||||
setItems(result)
|
||||
setError('')
|
||||
} catch (loadError) {
|
||||
setError(resolveErrorMessage(loadError, '加载通知配置失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
async function openEdit(id: number) {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const detail = await getNotification(id)
|
||||
setEditingItem(detail)
|
||||
setDrawerVisible(true)
|
||||
} catch (loadError) {
|
||||
Message.error(resolveErrorMessage(loadError, '加载通知详情失败'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(value: NotificationPayload, notificationId?: number) {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (notificationId) {
|
||||
await updateNotification(notificationId, value)
|
||||
Message.success('通知配置已更新')
|
||||
} else {
|
||||
await createNotification(value)
|
||||
Message.success('通知配置已创建')
|
||||
}
|
||||
setDrawerVisible(false)
|
||||
setEditingItem(null)
|
||||
await loadData()
|
||||
} catch (submitError) {
|
||||
Message.error(resolveErrorMessage(submitError, '保存通知配置失败'))
|
||||
throw submitError
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest(value: NotificationPayload, notificationId?: number) {
|
||||
setTesting(true)
|
||||
try {
|
||||
if (notificationId) {
|
||||
await testSavedNotification(notificationId)
|
||||
} else {
|
||||
await testNotification(value)
|
||||
}
|
||||
Message.success('测试通知已发出,请查收')
|
||||
} catch (testError) {
|
||||
Message.error(resolveErrorMessage(testError, '发送测试通知失败'))
|
||||
throw testError
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: NotificationSummary) {
|
||||
if (!window.confirm(`确定删除通知配置“${item.name}”吗?`)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteNotification(item.id)
|
||||
Message.success('通知配置已删除')
|
||||
await loadData()
|
||||
} catch (deleteError) {
|
||||
Message.error(resolveErrorMessage(deleteError, '删除通知配置失败'))
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
render: (_: unknown, record: NotificationSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text bold>{record.name}</Typography.Text>
|
||||
<Space>
|
||||
{getNotificationTypeLabel(record.type) && <Tag color="arcoblue" bordered>{getNotificationTypeLabel(record.type)}</Tag>}
|
||||
{record.enabled !== undefined && <Tag color={record.enabled ? 'green' : 'gray'} bordered>{record.enabled ? '已启用' : '已停用'}</Tag>}
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '触发条件',
|
||||
dataIndex: 'events',
|
||||
render: (_: unknown, record: NotificationSummary) => (
|
||||
<Space>
|
||||
{record.onSuccess ? <Tag color="green" bordered>成功</Tag> : null}
|
||||
{record.onFailure ? <Tag color="red" bordered>失败</Tag> : null}
|
||||
{!record.onSuccess && !record.onFailure ? <Tag color="gray" bordered>未配置</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
render: (value: string) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'actions',
|
||||
width: 180,
|
||||
render: (_: unknown, record: NotificationSummary) => (
|
||||
<Space>
|
||||
<Button size="small" type="text" onClick={() => void openEdit(record.id)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="small" type="text" status="danger" onClick={() => void handleDelete(record)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<PageHeader
|
||||
style={{ paddingBottom: 16 }}
|
||||
title="通知配置"
|
||||
subTitle="配置 Email、Webhook 与 Telegram 渠道,并控制成功/失败事件的发送策略"
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setEditingItem(null)
|
||||
setDrawerVisible(true)
|
||||
}}
|
||||
>
|
||||
新建通知
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
|
||||
|
||||
<Card>
|
||||
<Table rowKey="id" loading={loading} columns={columns} data={items} pagination={{ pageSize: 10 }} stripe noDataElement={<Empty description="暂无通知配置,请先创建" />} />
|
||||
</Card>
|
||||
|
||||
<NotificationFormDrawer
|
||||
visible={drawerVisible}
|
||||
loading={submitting}
|
||||
testing={testing}
|
||||
initialValue={editingItem}
|
||||
onCancel={() => {
|
||||
setDrawerVisible(false)
|
||||
setEditingItem(null)
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
onTest={handleTest}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
88
web/src/pages/settings/SettingsPage.tsx
Normal file
88
web/src/pages/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Card, Descriptions, Grid, PageHeader, Space, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchSystemInfo, type SystemInfo } from '../../services/system'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatDuration } from '../../utils/format'
|
||||
|
||||
const { Row, Col } = Grid
|
||||
|
||||
const deploySteps = [
|
||||
'1. 构建前端:cd web && npm run build',
|
||||
'2. 编译后端:cd server && go build -o backupx ./cmd/backupx',
|
||||
'3. 部署静态资源与二进制,并按 deploy/ 目录提供的配置接入 Nginx 与 systemd',
|
||||
'4. 首次启动后访问 Web 控制台,完成管理员初始化与存储目标配置',
|
||||
]
|
||||
|
||||
export function SettingsPage() {
|
||||
const [info, setInfo] = useState<SystemInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await fetchSystemInfo()
|
||||
if (active) {
|
||||
setInfo(result)
|
||||
setError('')
|
||||
}
|
||||
} catch (loadError) {
|
||||
if (active) {
|
||||
setError(resolveErrorMessage(loadError, '加载系统设置失败'))
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<PageHeader
|
||||
style={{ paddingBottom: 16 }}
|
||||
title="系统设置"
|
||||
subTitle="展示当前运行信息、部署入口和交付所需的基础操作说明"
|
||||
>
|
||||
{error ? <Typography.Text type="error">{error}</Typography.Text> : null}
|
||||
</PageHeader>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card loading={loading} title="运行信息">
|
||||
<Descriptions
|
||||
column={1}
|
||||
border
|
||||
data={[
|
||||
{ label: '版本', value: info?.version ?? '-' },
|
||||
{ label: '运行模式', value: info?.mode ?? '-' },
|
||||
{ label: '运行时长', value: formatDuration(info?.uptimeSeconds) },
|
||||
{ label: '启动时间', value: info?.startedAt ?? '-' },
|
||||
{ label: '数据库路径', value: info?.databasePath ?? '-' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="部署资产">
|
||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||
<Typography.Text>`deploy/nginx.conf`:静态资源托管与 `/api` 反向代理示例。</Typography.Text>
|
||||
<Typography.Text>`deploy/backupx.service`:systemd 服务单元,负责守护 API 进程。</Typography.Text>
|
||||
<Typography.Text>`deploy/install.sh`:一键安装示例脚本,用于创建目录、复制文件并启动服务。</Typography.Text>
|
||||
<Typography.Text>`README.md`:包含完整部署与使用文档。</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="部署步骤">
|
||||
<div className="code-block">{deploySteps.join('\n')}</div>
|
||||
</Card>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
106
web/src/pages/storage-targets/GoogleDriveCallbackPage.tsx
Normal file
106
web/src/pages/storage-targets/GoogleDriveCallbackPage.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Alert, Button, Card, Space, Spin, Typography } from '@arco-design/web-react'
|
||||
import axios from 'axios'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { completeGoogleDriveAuth } from '../../services/storage-targets'
|
||||
import type { GoogleDriveCallbackResult } from '../../types/storage-targets'
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
return error.response?.data?.message ?? 'Google Drive 授权回调失败'
|
||||
}
|
||||
return 'Google Drive 授权回调失败'
|
||||
}
|
||||
|
||||
// Define outside the component to survive React StrictMode unmount/remount
|
||||
let globalAuthPromise: Promise<GoogleDriveCallbackResult> | null = null
|
||||
|
||||
export function GoogleDriveCallbackPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [result, setResult] = useState<GoogleDriveCallbackResult | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [countdown, setCountdown] = useState(3)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
if (!globalAuthPromise) {
|
||||
globalAuthPromise = completeGoogleDriveAuth(searchParams.toString())
|
||||
}
|
||||
|
||||
globalAuthPromise
|
||||
.then((response) => {
|
||||
if (active) setResult(response)
|
||||
})
|
||||
.catch((callbackError) => {
|
||||
if (active) setError(resolveErrorMessage(callbackError))
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// Auto-close countdown on success
|
||||
useEffect(() => {
|
||||
if (!result?.success) return
|
||||
if (countdown <= 0) {
|
||||
window.close()
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [result, countdown])
|
||||
|
||||
function handleClose() {
|
||||
window.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', padding: 24 }}>
|
||||
<Card style={{ maxWidth: 520, width: '100%' }}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title heading={4}>Google Drive 授权结果</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
BackupX 正在处理 Google Drive OAuth 回调结果。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
{loading ? <Spin tip="正在完成授权..." style={{ width: '100%' }} /> : null}
|
||||
|
||||
{!loading && error ? <Alert type="error" content={error} /> : null}
|
||||
|
||||
{!loading && !error && result ? (
|
||||
<Alert
|
||||
type={result.success ? 'success' : 'warning'}
|
||||
content={
|
||||
result.success
|
||||
? `${result.message},此页面将在 ${countdown} 秒后自动关闭...`
|
||||
: result.message
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Space>
|
||||
{!loading && result?.success ? (
|
||||
<Button type="primary" onClick={handleClose}>
|
||||
立即关闭此页面
|
||||
</Button>
|
||||
) : null}
|
||||
{!loading && (error || !result?.success) ? (
|
||||
<Button type="primary" onClick={handleClose}>
|
||||
关闭页面
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
246
web/src/pages/storage-targets/StorageTargetsPage.tsx
Normal file
246
web/src/pages/storage-targets/StorageTargetsPage.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { Alert, Button, Card, Empty, Grid, Message, PageHeader, Space, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||
import axios from 'axios'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
createStorageTarget,
|
||||
deleteStorageTarget,
|
||||
getStorageTarget,
|
||||
listStorageTargets,
|
||||
startGoogleDriveAuth,
|
||||
testSavedStorageTarget,
|
||||
testStorageTarget,
|
||||
updateStorageTarget,
|
||||
} from '../../services/storage-targets'
|
||||
import type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
|
||||
import { getStorageTargetTypeLabel } from '../../components/storage-targets/field-config'
|
||||
import { StorageTargetFormDrawer } from '../../components/storage-targets/StorageTargetFormDrawer'
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
return error.response?.data?.message ?? '请求失败,请稍后重试'
|
||||
}
|
||||
return '请求失败,请稍后重试'
|
||||
}
|
||||
|
||||
function renderTestStatus(target: StorageTargetSummary) {
|
||||
switch (target.lastTestStatus) {
|
||||
case 'success':
|
||||
return <Tag color="green" bordered>连接正常</Tag>
|
||||
case 'failed':
|
||||
return <Tag color="red" bordered>最近测试失败</Tag>
|
||||
default:
|
||||
return <Tag color="arcoblue" bordered>未测试</Tag>
|
||||
}
|
||||
}
|
||||
|
||||
export function StorageTargetsPage() {
|
||||
const [targets, setTargets] = useState<StorageTargetSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
const [editingTarget, setEditingTarget] = useState<StorageTargetDetail | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const loadTargets = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await listStorageTargets()
|
||||
setTargets(result)
|
||||
setError('')
|
||||
} catch (loadError) {
|
||||
setError(resolveErrorMessage(loadError))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadTargets()
|
||||
}, [loadTargets])
|
||||
|
||||
// Auto-refresh when user comes back from Google Drive OAuth tab
|
||||
useEffect(() => {
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void loadTargets()
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}, [loadTargets])
|
||||
|
||||
async function openEdit(id: number) {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const detail = await getStorageTarget(id)
|
||||
setEditingTarget(detail)
|
||||
setDrawerVisible(true)
|
||||
} catch (loadError) {
|
||||
Message.error(resolveErrorMessage(loadError))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(value: StorageTargetPayload, targetId?: number) {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (targetId) {
|
||||
await updateStorageTarget(targetId, value)
|
||||
Message.success('存储目标已更新')
|
||||
} else {
|
||||
await createStorageTarget(value)
|
||||
Message.success('存储目标已创建')
|
||||
}
|
||||
setDrawerVisible(false)
|
||||
setEditingTarget(null)
|
||||
await loadTargets()
|
||||
} catch (submitError) {
|
||||
Message.error(resolveErrorMessage(submitError))
|
||||
throw submitError
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (!window.confirm('确定删除该存储目标吗?')) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteStorageTarget(id)
|
||||
Message.success('存储目标已删除')
|
||||
await loadTargets()
|
||||
} catch (deleteError) {
|
||||
Message.error(resolveErrorMessage(deleteError))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDraftTest(value: StorageTargetPayload, targetId?: number): Promise<StorageConnectionTestResult> {
|
||||
setTesting(true)
|
||||
try {
|
||||
// When editing an existing target, use saved config test to avoid sending masked values
|
||||
const result = targetId
|
||||
? await testSavedStorageTarget(targetId)
|
||||
: await testStorageTarget(value)
|
||||
Message.success(result.message)
|
||||
if (targetId) {
|
||||
await loadTargets()
|
||||
}
|
||||
return result
|
||||
} catch (testError) {
|
||||
const message = resolveErrorMessage(testError)
|
||||
Message.error(message)
|
||||
return { success: false, message }
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavedTest(id: number) {
|
||||
try {
|
||||
const result = await testSavedStorageTarget(id)
|
||||
Message.success(result.message)
|
||||
await loadTargets()
|
||||
} catch (testError) {
|
||||
Message.error(resolveErrorMessage(testError))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleDriveAuth(value: StorageTargetPayload, targetId?: number) {
|
||||
try {
|
||||
const result = await startGoogleDriveAuth(value, targetId)
|
||||
window.open(result.authUrl, '_blank')
|
||||
} catch (authError) {
|
||||
Message.error(resolveErrorMessage(authError))
|
||||
throw authError
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<PageHeader
|
||||
style={{ paddingBottom: 16 }}
|
||||
title="存储目标"
|
||||
subTitle="管理本地磁盘、S3 Compatible、WebDAV 与 Google Drive 等备份目标"
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setEditingTarget(null)
|
||||
setDrawerVisible(true)
|
||||
}}
|
||||
>
|
||||
新建存储目标
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? <Alert type="error" content={error} /> : null}
|
||||
|
||||
{loading ? (
|
||||
<Spin />
|
||||
) : targets.length === 0 ? (
|
||||
<Card>
|
||||
<Empty description="暂无存储目标,请先创建一个备份落地点。" />
|
||||
</Card>
|
||||
) : (
|
||||
<Grid.Row gutter={[16, 16]}>
|
||||
{targets.map((target) => (
|
||||
<Grid.Col span={8} key={target.id}>
|
||||
<Card style={{ height: '100%' }}>
|
||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||
<Space size="large" align="start" style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title heading={6} style={{ marginBottom: 4 }}>
|
||||
{target.name}
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
{getStorageTargetTypeLabel(target.type) && <Tag color="arcoblue" bordered>{getStorageTargetTypeLabel(target.type)}</Tag>}
|
||||
{target.enabled ? <Tag color="green" bordered>已启用</Tag> : <Tag color="gray" bordered>已停用</Tag>}
|
||||
{renderTestStatus(target)}
|
||||
</Space>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
{target.description ? <Typography.Paragraph>{target.description}</Typography.Paragraph> : null}
|
||||
{target.lastTestMessage ? (
|
||||
<Typography.Paragraph type="secondary">最近测试:{target.lastTestMessage}</Typography.Paragraph>
|
||||
) : null}
|
||||
<Typography.Text type="secondary">更新时间:{target.updatedAt}</Typography.Text>
|
||||
|
||||
<Space wrap size="mini">
|
||||
<Button size="small" type="text" onClick={() => void openEdit(target.id)} loading={submitting && editingTarget?.id === target.id}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="small" type="text" onClick={() => void handleSavedTest(target.id)}>
|
||||
测试连接
|
||||
</Button>
|
||||
<Button size="small" type="text" status="danger" onClick={() => void handleDelete(target.id)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid.Row>
|
||||
)}
|
||||
|
||||
<StorageTargetFormDrawer
|
||||
visible={drawerVisible}
|
||||
loading={submitting}
|
||||
testing={testing}
|
||||
initialValue={editingTarget}
|
||||
onCancel={() => {
|
||||
setDrawerVisible(false)
|
||||
setEditingTarget(null)
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
onTest={handleDraftTest}
|
||||
onGoogleDriveAuth={handleGoogleDriveAuth}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
67
web/src/pages/system-info/SystemInfoPage.tsx
Normal file
67
web/src/pages/system-info/SystemInfoPage.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Alert, Card, Descriptions, Space, Spin, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { fetchSystemInfo, type SystemInfo } from '../../services/system'
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
return error.response?.data?.message ?? '加载系统信息失败'
|
||||
}
|
||||
return '加载系统信息失败'
|
||||
}
|
||||
|
||||
export function SystemInfoPage() {
|
||||
const [data, setData] = useState<SystemInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await fetchSystemInfo()
|
||||
if (mounted) {
|
||||
setData(result)
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setError(resolveErrorMessage(err))
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title heading={4}>系统信息</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
用于确认服务版本、运行模式、数据库位置与运行时长。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{loading ? (
|
||||
<Spin />
|
||||
) : error ? (
|
||||
<Alert type="error" content={error} />
|
||||
) : (
|
||||
<Descriptions column={1} border data={[
|
||||
{ label: '版本', value: data?.version ?? '-' },
|
||||
{ label: '运行模式', value: data?.mode ?? '-' },
|
||||
{ label: '启动时间', value: data?.startedAt ?? '-' },
|
||||
{ label: '运行秒数', value: data?.uptimeSeconds ?? '-' },
|
||||
{ label: '数据库路径', value: data?.databasePath ?? '-' },
|
||||
]} />
|
||||
)}
|
||||
</Card>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
97
web/src/pages/system-info/page.tsx
Normal file
97
web/src/pages/system-info/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Alert,
|
||||
Descriptions,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@arco-design/web-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { PageCard } from '../../components/page-card';
|
||||
import { systemApi } from '../../services/system';
|
||||
import type { SystemInfo } from '../../types/system';
|
||||
|
||||
function formatUptime(seconds: number) {
|
||||
if (seconds < 60) {
|
||||
return `${seconds} 秒`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
return `${hours} 小时 ${minutes} 分钟`;
|
||||
}
|
||||
|
||||
export function SystemInfoPage() {
|
||||
const [data, setData] = useState<SystemInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadSystemInfo() {
|
||||
try {
|
||||
const result = await systemApi.fetchInfo();
|
||||
|
||||
if (active) {
|
||||
setData(result);
|
||||
setError(null);
|
||||
}
|
||||
} catch (loadError) {
|
||||
if (active) {
|
||||
setError(loadError instanceof Error ? loadError.message : '系统信息加载失败');
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadSystemInfo();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fullscreen-center">
|
||||
<Spin tip="正在加载系统信息..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert type="error" content={error} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Alert type="warning" content="未获取到系统信息" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<PageCard title="系统信息">
|
||||
<Typography.Paragraph type="secondary">
|
||||
用于确认 API 服务已正常启动,并展示平台基础运行状态。
|
||||
</Typography.Paragraph>
|
||||
<Descriptions
|
||||
column={1}
|
||||
data={[
|
||||
{ label: '版本', value: data.version },
|
||||
{
|
||||
label: '运行模式',
|
||||
value: <Tag color={data.mode === 'release' ? 'green' : 'arcoblue'}>{data.mode}</Tag>,
|
||||
},
|
||||
{ label: '启动时间', value: data.startedAt },
|
||||
{ label: '运行时长', value: formatUptime(data.uptimeSeconds) },
|
||||
{ label: '数据库路径', value: data.databasePath },
|
||||
]}
|
||||
/>
|
||||
</PageCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user