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

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

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

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

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

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

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

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

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

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

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

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

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