mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-11 18:10:23 +08:00
first commit
This commit is contained in:
12
web/index.html
Normal file
12
web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BackupX</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3886
web/package-lock.json
generated
Normal file
3886
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
web/package.json
Normal file
37
web/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "backupx-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit -p tsconfig.json && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-react": "^2.66.0",
|
||||
"axios": "^1.8.4",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"i18next": "^25.8.14",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^16.5.6",
|
||||
"react-router-dom": "^6.30.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/react": "^18.3.20",
|
||||
"@types/react-dom": "^18.3.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^26.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.1",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
}
|
||||
13
web/src/RootApp.tsx
Normal file
13
web/src/RootApp.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { RouterView } from './router'
|
||||
import { AuthBootstrap } from './components/AuthBootstrap'
|
||||
|
||||
export function RootApp() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthBootstrap>
|
||||
<RouterView />
|
||||
</AuthBootstrap>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
13
web/src/app.tsx
Normal file
13
web/src/app.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { RouterView } from './router'
|
||||
import { AuthBootstrap } from './components/AuthBootstrap'
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthBootstrap>
|
||||
<RouterView />
|
||||
</AuthBootstrap>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
27
web/src/components/AuthBootstrap.tsx
Normal file
27
web/src/components/AuthBootstrap.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ReactNode, useEffect, useRef } from 'react'
|
||||
import { FullPageLoading } from './FullPageLoading'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
interface AuthBootstrapProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AuthBootstrap({ children }: AuthBootstrapProps) {
|
||||
const bootstrap = useAuthStore((state) => state.bootstrap)
|
||||
const bootstrapped = useAuthStore((state) => state.bootstrapped)
|
||||
const startedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (startedRef.current) {
|
||||
return
|
||||
}
|
||||
startedRef.current = true
|
||||
void bootstrap()
|
||||
}, [bootstrap])
|
||||
|
||||
if (!bootstrapped) {
|
||||
return <FullPageLoading tip="正在初始化登录状态..." />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
197
web/src/components/CronInput/CronInput.tsx
Normal file
197
web/src/components/CronInput/CronInput.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { Input, Space, Switch, Tabs, Typography, Radio, Checkbox, Select } from '@arco-design/web-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export interface CronInputProps {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
const DEFAULT_CRON = '* * * * *'
|
||||
|
||||
type CronPart = 'minute' | 'hour' | 'day' | 'month' | 'week'
|
||||
|
||||
interface CronState {
|
||||
minute: string
|
||||
hour: string
|
||||
day: string
|
||||
month: string
|
||||
week: string
|
||||
}
|
||||
|
||||
function parseCron(expr: string): CronState {
|
||||
const parts = (expr || DEFAULT_CRON).trim().split(/\s+/)
|
||||
return {
|
||||
minute: parts[0] || '*',
|
||||
hour: parts[1] || '*',
|
||||
day: parts[2] || '*',
|
||||
month: parts[3] || '*',
|
||||
week: parts[4] || '*',
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyCron(state: CronState): string {
|
||||
return `${state.minute} ${state.hour} ${state.day} ${state.month} ${state.week}`
|
||||
}
|
||||
|
||||
function generateOptions(min: number, max: number) {
|
||||
return Array.from({ length: max - min + 1 }, (_, i) => ({
|
||||
label: String(i + min),
|
||||
value: String(i + min),
|
||||
}))
|
||||
}
|
||||
|
||||
const MINUTES_OPTIONS = generateOptions(0, 59)
|
||||
const HOURS_OPTIONS = generateOptions(0, 23)
|
||||
const DAYS_OPTIONS = generateOptions(1, 31)
|
||||
const MONTHS_OPTIONS = generateOptions(1, 12)
|
||||
const WEEKS_OPTIONS = [
|
||||
{ label: '星期日', value: '0' },
|
||||
{ label: '星期一', value: '1' },
|
||||
{ label: '星期二', value: '2' },
|
||||
{ label: '星期三', value: '3' },
|
||||
{ label: '星期四', value: '4' },
|
||||
{ label: '星期五', value: '5' },
|
||||
{ label: '星期六', value: '6' },
|
||||
]
|
||||
|
||||
export function CronInput({ value, onChange }: CronInputProps) {
|
||||
const [internalValue, setInternalValue] = useState(value || DEFAULT_CRON)
|
||||
const [isAdvanced, setIsAdvanced] = useState(false)
|
||||
const [state, setState] = useState<CronState>(parseCron(internalValue))
|
||||
|
||||
// Sync prop to internal state
|
||||
useEffect(() => {
|
||||
if (value !== undefined && value !== internalValue) {
|
||||
setInternalValue(value || DEFAULT_CRON)
|
||||
if (!isAdvanced) {
|
||||
setState(parseCron(value || DEFAULT_CRON))
|
||||
}
|
||||
}
|
||||
}, [value, isAdvanced, internalValue])
|
||||
|
||||
const notifyChange = (nextValue: string) => {
|
||||
setInternalValue(nextValue)
|
||||
if (onChange) {
|
||||
onChange(nextValue)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStateChange = (part: CronPart, val: string) => {
|
||||
const nextState = { ...state, [part]: val }
|
||||
setState(nextState)
|
||||
notifyChange(stringifyCron(nextState))
|
||||
}
|
||||
|
||||
const renderPartTab = (
|
||||
part: CronPart,
|
||||
title: string,
|
||||
options: { label: string; value: string }[],
|
||||
allowAnyVal = '*',
|
||||
) => {
|
||||
const currentVal = state[part]
|
||||
const isAny = currentVal === allowAnyVal || currentVal === '*' || currentVal === '?'
|
||||
const isSpecific = !isAny && !currentVal.includes('/') && !currentVal.includes('-')
|
||||
|
||||
// For simplicity in this visual editor, we only support "every" (*) and "specific values" (1,2,3).
|
||||
const type = isAny ? 'any' : 'specific'
|
||||
const specificValues = isSpecific ? currentVal.split(',') : []
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<Radio.Group
|
||||
direction="vertical"
|
||||
value={type}
|
||||
onChange={(val) => {
|
||||
if (val === 'any') {
|
||||
handleStateChange(part, allowAnyVal)
|
||||
} else {
|
||||
handleStateChange(part, options[0].value) // Default to first valid item
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Radio value="any">
|
||||
<Typography.Text>通配 ({allowAnyVal}) - 任意{title}</Typography.Text>
|
||||
</Radio>
|
||||
<Radio value="specific">
|
||||
<Typography.Text>指定{title}</Typography.Text>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
|
||||
{type === 'specific' && (
|
||||
<div style={{ paddingLeft: 24, marginTop: 12 }}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={`请选择${title}`}
|
||||
value={specificValues}
|
||||
options={options}
|
||||
onChange={(vals: string[]) => {
|
||||
if (vals.length === 0) {
|
||||
handleStateChange(part, allowAnyVal)
|
||||
} else {
|
||||
// Sort numerically to keep things neat
|
||||
const sorted = [...vals].sort((a, b) => Number(a) - Number(b))
|
||||
handleStateChange(part, sorted.join(','))
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%', maxWidth: 400 }}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cron-input-container">
|
||||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Input
|
||||
value={internalValue}
|
||||
onChange={(val) => {
|
||||
setInternalValue(val)
|
||||
if (isAdvanced && onChange) {
|
||||
onChange(val)
|
||||
}
|
||||
}}
|
||||
readOnly={!isAdvanced}
|
||||
style={{ width: 240, fontFamily: 'monospace' }}
|
||||
placeholder="* * * * *"
|
||||
/>
|
||||
<Space>
|
||||
<Typography.Text type="secondary">高级模式 (手动输入)</Typography.Text>
|
||||
<Switch
|
||||
checked={isAdvanced}
|
||||
onChange={(checked) => {
|
||||
setIsAdvanced(checked)
|
||||
if (!checked) {
|
||||
// When switching back to visual, parse the current raw value
|
||||
setState(parseCron(internalValue))
|
||||
notifyChange(stringifyCron(parseCron(internalValue)))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{!isAdvanced && (
|
||||
<Tabs type="card-gutter" size="small">
|
||||
<Tabs.TabPane key="minute" title="分钟">
|
||||
{renderPartTab('minute', '分钟', MINUTES_OPTIONS, '*')}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="hour" title="小时">
|
||||
{renderPartTab('hour', '小时', HOURS_OPTIONS, '*')}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="day" title="日">
|
||||
{renderPartTab('day', '日', DAYS_OPTIONS, '*')}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="month" title="月">
|
||||
{renderPartTab('month', '月', MONTHS_OPTIONS, '*')}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="week" title="周">
|
||||
{renderPartTab('week', '周', WEEKS_OPTIONS, '*')}
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2
web/src/components/CronInput/index.ts
Normal file
2
web/src/components/CronInput/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CronInput } from './CronInput'
|
||||
export type { CronInputProps } from './CronInput'
|
||||
16
web/src/components/FullPageLoading.tsx
Normal file
16
web/src/components/FullPageLoading.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Space, Spin, Typography } from '@arco-design/web-react'
|
||||
|
||||
interface FullPageLoadingProps {
|
||||
tip: string
|
||||
}
|
||||
|
||||
export function FullPageLoading({ tip }: FullPageLoadingProps) {
|
||||
return (
|
||||
<div className="full-page-shell">
|
||||
<Space direction="vertical" size="large" align="center">
|
||||
<Spin size={32} />
|
||||
<Typography.Text>{tip}</Typography.Text>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
web/src/components/auth-guard.test.tsx
Normal file
58
web/src/components/auth-guard.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { AuthGuard } from './auth-guard';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
function renderWithRoutes(initialEntry: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>login-page</div>} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<AuthGuard>
|
||||
<div>protected-page</div>
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('AuthGuard', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
token: null,
|
||||
user: null,
|
||||
hydrated: true,
|
||||
status: 'anonymous',
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects anonymous users to login page', async () => {
|
||||
renderWithRoutes('/');
|
||||
|
||||
expect(await screen.findByText('login-page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children for authenticated users', async () => {
|
||||
useAuthStore.setState({
|
||||
token: 'token',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
displayName: '管理员',
|
||||
role: 'admin',
|
||||
},
|
||||
hydrated: true,
|
||||
status: 'authenticated',
|
||||
});
|
||||
|
||||
renderWithRoutes('/');
|
||||
|
||||
expect(await screen.findByText('protected-page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
25
web/src/components/auth-guard.tsx
Normal file
25
web/src/components/auth-guard.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Spin } from '@arco-design/web-react';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
export function AuthGuard({ children }: PropsWithChildren) {
|
||||
const hydrated = useAuthStore((state) => state.hydrated);
|
||||
const status = useAuthStore((state) => state.status);
|
||||
const location = useLocation();
|
||||
|
||||
if (!hydrated || status === 'bootstrapping' || status === 'idle') {
|
||||
return (
|
||||
<div className="fullscreen-center">
|
||||
<Spin tip="正在加载登录状态..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== 'authenticated') {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
232
web/src/components/backup-records/BackupRecordLogDrawer.tsx
Normal file
232
web/src/components/backup-records/BackupRecordLogDrawer.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { Alert, Button, Descriptions, Drawer, Space, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { deleteBackupRecord, downloadBackupRecord, getBackupRecord, restoreBackupRecord, streamBackupRecordLogs } from '../../services/backup-records'
|
||||
import type { BackupLogEvent, BackupRecordDetail, BackupRecordStatus } from '../../types/backup-records'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatBytes, formatDateTime, formatDuration } from '../../utils/format'
|
||||
|
||||
interface BackupRecordLogDrawerProps {
|
||||
visible: boolean
|
||||
recordId?: number
|
||||
onCancel: () => void
|
||||
onChanged?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
function getStatusColor(status: BackupRecordStatus) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'green'
|
||||
case 'failed':
|
||||
return 'red'
|
||||
default:
|
||||
return 'arcoblue'
|
||||
}
|
||||
}
|
||||
|
||||
function buildLogText(record: BackupRecordDetail | null, events: BackupLogEvent[]) {
|
||||
if (events.length > 0) {
|
||||
return events.map((item) => `[${formatDateTime(item.timestamp)}] ${item.message}`).join('\n')
|
||||
}
|
||||
return record?.logContent ?? ''
|
||||
}
|
||||
|
||||
export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }: BackupRecordLogDrawerProps) {
|
||||
const [record, setRecord] = useState<BackupRecordDetail | null>(null)
|
||||
const [events, setEvents] = useState<BackupLogEvent[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [acting, setActing] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [streamError, setStreamError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !recordId) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentRecordId = recordId
|
||||
let active = true
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
async function loadRecordDetail() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const detail = await getBackupRecord(currentRecordId)
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
setRecord(detail)
|
||||
setEvents(detail.logEvents ?? [])
|
||||
setError('')
|
||||
setStreamError('')
|
||||
|
||||
if (detail.status === 'running') {
|
||||
unsubscribe = streamBackupRecordLogs(currentRecordId, {
|
||||
onEvent: (event) => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
setEvents((current) => {
|
||||
if (current.some((item) => item.sequence === event.sequence)) {
|
||||
return current
|
||||
}
|
||||
return [...current, event]
|
||||
})
|
||||
if (event.completed) {
|
||||
setRecord((current) => (current ? { ...current, status: event.status as BackupRecordStatus } : current))
|
||||
}
|
||||
},
|
||||
onDone: () => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const latest = await getBackupRecord(currentRecordId)
|
||||
if (active) {
|
||||
setRecord(latest)
|
||||
setEvents(latest.logEvents ?? [])
|
||||
}
|
||||
} catch (streamLoadError) {
|
||||
if (active) {
|
||||
setStreamError(resolveErrorMessage(streamLoadError, '刷新日志详情失败'))
|
||||
}
|
||||
}
|
||||
})()
|
||||
},
|
||||
onError: (message) => {
|
||||
if (active) {
|
||||
setStreamError(message)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (loadError) {
|
||||
if (active) {
|
||||
setError(resolveErrorMessage(loadError, '加载备份记录失败'))
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadRecordDetail()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [recordId, visible])
|
||||
|
||||
const logText = useMemo(() => buildLogText(record, events), [events, record])
|
||||
|
||||
async function handleDownload() {
|
||||
if (!recordId) {
|
||||
return
|
||||
}
|
||||
setActing(true)
|
||||
try {
|
||||
const result = await downloadBackupRecord(recordId)
|
||||
const url = window.URL.createObjectURL(result.blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = result.fileName
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (downloadError) {
|
||||
setStreamError(resolveErrorMessage(downloadError, '下载备份文件失败'))
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestore() {
|
||||
if (!recordId) {
|
||||
return
|
||||
}
|
||||
setActing(true)
|
||||
try {
|
||||
await restoreBackupRecord(recordId)
|
||||
setStreamError('恢复命令已提交')
|
||||
await onChanged?.()
|
||||
} catch (restoreError) {
|
||||
setStreamError(resolveErrorMessage(restoreError, '恢复备份失败'))
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!recordId) {
|
||||
return
|
||||
}
|
||||
if (!window.confirm('确定删除该备份记录及远端对象吗?')) {
|
||||
return
|
||||
}
|
||||
setActing(true)
|
||||
try {
|
||||
await deleteBackupRecord(recordId)
|
||||
await onChanged?.()
|
||||
onCancel()
|
||||
} catch (deleteError) {
|
||||
setStreamError(resolveErrorMessage(deleteError, '删除备份记录失败'))
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer width={720} title="备份记录详情" visible={visible} onCancel={onCancel}>
|
||||
{loading ? (
|
||||
<Spin />
|
||||
) : error ? (
|
||||
<Alert type="error" content={error} />
|
||||
) : record ? (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{streamError ? <Alert type="warning" content={streamError} /> : null}
|
||||
<div>
|
||||
<Typography.Title heading={6} style={{ marginTop: 0, marginBottom: 4 }}>
|
||||
{record.taskName}
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
{record.status && (
|
||||
<Tag color={getStatusColor(record.status)} bordered>
|
||||
{record.status === 'success' ? '成功' : record.status === 'failed' ? '失败' : record.status === 'running' ? '执行中' : record.status}
|
||||
</Tag>
|
||||
)}
|
||||
{record.storageTargetName && <Tag color="arcoblue" bordered>{record.storageTargetName}</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
<Descriptions
|
||||
column={1}
|
||||
data={[
|
||||
{ label: '文件名', value: record.fileName || '-' },
|
||||
{ label: '文件大小', value: formatBytes(record.fileSize) },
|
||||
{ label: '存储路径', value: record.storagePath || '-' },
|
||||
{ label: '开始时间', value: formatDateTime(record.startedAt) },
|
||||
{ label: '完成时间', value: formatDateTime(record.completedAt) },
|
||||
{ label: '耗时', value: formatDuration(record.durationSeconds) },
|
||||
{ label: '错误信息', value: record.errorMessage || '-' },
|
||||
]}
|
||||
/>
|
||||
<Space>
|
||||
<Button loading={acting} onClick={handleDownload}>
|
||||
下载
|
||||
</Button>
|
||||
<Button loading={acting} onClick={handleRestore}>
|
||||
恢复
|
||||
</Button>
|
||||
<Button loading={acting} status="danger" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
<div>
|
||||
<Typography.Title heading={6}>执行日志</Typography.Title>
|
||||
<div className="log-viewer">{logText || '暂无日志输出'}</div>
|
||||
</div>
|
||||
</Space>
|
||||
) : null}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
63
web/src/components/backup-tasks/BackupTaskDetailDrawer.tsx
Normal file
63
web/src/components/backup-tasks/BackupTaskDetailDrawer.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Descriptions, Drawer, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import type { BackupTaskDetail } from '../../types/backup-tasks'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
import { getBackupTaskStatusColor, getBackupTaskStatusLabel, getBackupTaskTypeLabel } from './field-config'
|
||||
|
||||
interface BackupTaskDetailDrawerProps {
|
||||
visible: boolean
|
||||
task: BackupTaskDetail | null
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function BackupTaskDetailDrawer({ visible, task, onCancel }: BackupTaskDetailDrawerProps) {
|
||||
return (
|
||||
<Drawer width={560} title="任务详情" visible={visible} onCancel={onCancel}>
|
||||
{task ? (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title heading={6} style={{ marginTop: 0, marginBottom: 4 }}>
|
||||
{task.name}
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
<Tag color="arcoblue" bordered>{getBackupTaskTypeLabel(task.type)}</Tag>
|
||||
<Tag color={task.enabled ? 'green' : 'gray'} bordered>{task.enabled ? '已启用' : '已停用'}</Tag>
|
||||
<Tag color={getBackupTaskStatusColor(task.lastStatus)} bordered>{getBackupTaskStatusLabel(task.lastStatus)}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
<Descriptions
|
||||
column={1}
|
||||
border
|
||||
data={[
|
||||
{ label: 'Cron', value: task.cronExpr || '仅手动执行' },
|
||||
{ label: '存储目标', value: task.storageTargetName || task.storageTargetId },
|
||||
{ label: '保留天数', value: task.retentionDays },
|
||||
{ label: '最大保留份数', value: task.maxBackups },
|
||||
{ label: '压缩', value: task.compression },
|
||||
{ label: '加密', value: task.encrypt ? '已启用' : '未启用' },
|
||||
{ label: '最近执行', value: formatDateTime(task.lastRunAt) },
|
||||
{ label: '创建时间', value: formatDateTime(task.createdAt) },
|
||||
{ label: '更新时间', value: formatDateTime(task.updatedAt) },
|
||||
]}
|
||||
/>
|
||||
{task.type === 'file' ? (
|
||||
<Descriptions border column={1} data={[{ label: '源路径', value: task.sourcePath || '-' }, { label: '排除规则', value: task.excludePatterns.join(', ') || '-' }]} />
|
||||
) : null}
|
||||
{task.type === 'sqlite' ? <Descriptions border column={1} data={[{ label: 'SQLite 路径', value: task.dbPath || '-' }]} /> : null}
|
||||
{task.type === 'mysql' || task.type === 'postgresql' ? (
|
||||
<Descriptions
|
||||
column={1}
|
||||
border
|
||||
data={[
|
||||
{ label: '数据库主机', value: task.dbHost || '-' },
|
||||
{ label: '数据库端口', value: task.dbPort || '-' },
|
||||
{ label: '数据库用户', value: task.dbUser || '-' },
|
||||
{ label: '数据库名称', value: task.dbName || '-' },
|
||||
{ label: '数据库密码', value: task.maskedFields?.includes('dbPassword') ? '已配置' : '未配置' },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
) : null}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
325
web/src/components/backup-tasks/BackupTaskFormDrawer.tsx
Normal file
325
web/src/components/backup-tasks/BackupTaskFormDrawer.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { Alert, Button, Divider, Drawer, Input, InputNumber, Select, Space, Steps, Switch, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { CronInput } from '../CronInput'
|
||||
import type { StorageTargetSummary } from '../../types/storage-targets'
|
||||
import type { BackupTaskDetail, BackupTaskPayload, BackupTaskType } from '../../types/backup-tasks'
|
||||
import {
|
||||
backupCompressionOptions,
|
||||
backupTaskTypeOptions,
|
||||
getDefaultPort,
|
||||
isDatabaseBackupTask,
|
||||
isFileBackupTask,
|
||||
isSQLiteBackupTask,
|
||||
} from './field-config'
|
||||
|
||||
interface BackupTaskFormDrawerProps {
|
||||
visible: boolean
|
||||
loading: boolean
|
||||
initialValue: BackupTaskDetail | null
|
||||
storageTargets: StorageTargetSummary[]
|
||||
onCancel: () => void
|
||||
onSubmit: (value: BackupTaskPayload, taskId?: number) => Promise<void>
|
||||
}
|
||||
|
||||
function createEmptyDraft(storageTargetId?: number): BackupTaskPayload {
|
||||
return {
|
||||
name: '',
|
||||
type: 'file',
|
||||
enabled: true,
|
||||
cronExpr: '',
|
||||
sourcePath: '',
|
||||
excludePatterns: [],
|
||||
dbHost: '',
|
||||
dbPort: 0,
|
||||
dbUser: '',
|
||||
dbPassword: '',
|
||||
dbName: '',
|
||||
dbPath: '',
|
||||
storageTargetId: storageTargetId ?? 0,
|
||||
nodeId: 0,
|
||||
tags: '',
|
||||
retentionDays: 30,
|
||||
compression: 'gzip',
|
||||
encrypt: false,
|
||||
maxBackups: 10,
|
||||
}
|
||||
}
|
||||
|
||||
export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTargets, onCancel, onSubmit }: BackupTaskFormDrawerProps) {
|
||||
const [draft, setDraft] = useState<BackupTaskPayload>(createEmptyDraft())
|
||||
const [excludePatternsText, setExcludePatternsText] = useState('')
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!initialValue) {
|
||||
const nextDraft = createEmptyDraft(storageTargets[0]?.id)
|
||||
setDraft(nextDraft)
|
||||
setExcludePatternsText('')
|
||||
setCurrentStep(0)
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
|
||||
setDraft({
|
||||
name: initialValue.name,
|
||||
type: initialValue.type,
|
||||
enabled: initialValue.enabled,
|
||||
cronExpr: initialValue.cronExpr,
|
||||
sourcePath: initialValue.sourcePath,
|
||||
excludePatterns: initialValue.excludePatterns,
|
||||
dbHost: initialValue.dbHost,
|
||||
dbPort: initialValue.dbPort,
|
||||
dbUser: initialValue.dbUser,
|
||||
dbPassword: '',
|
||||
dbName: initialValue.dbName,
|
||||
dbPath: initialValue.dbPath,
|
||||
storageTargetId: initialValue.storageTargetId,
|
||||
nodeId: (initialValue as any).nodeId ?? 0,
|
||||
tags: (initialValue as any).tags ?? '',
|
||||
retentionDays: initialValue.retentionDays,
|
||||
compression: initialValue.compression,
|
||||
encrypt: initialValue.encrypt,
|
||||
maxBackups: initialValue.maxBackups,
|
||||
})
|
||||
setExcludePatternsText(initialValue.excludePatterns.join('\n'))
|
||||
setCurrentStep(0)
|
||||
setError('')
|
||||
}, [initialValue, storageTargets, visible])
|
||||
|
||||
const storageTargetOptions = useMemo(
|
||||
() => storageTargets.map((item) => ({ label: item.name, value: item.id, disabled: !item.enabled })),
|
||||
[storageTargets],
|
||||
)
|
||||
|
||||
function updateDraft(patch: Partial<BackupTaskPayload>) {
|
||||
setDraft((current) => ({ ...current, ...patch }))
|
||||
}
|
||||
|
||||
function updateTaskType(value: BackupTaskType) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
type: value,
|
||||
sourcePath: value === 'file' ? current.sourcePath : '',
|
||||
excludePatterns: value === 'file' ? current.excludePatterns : [],
|
||||
dbHost: value === 'mysql' || value === 'postgresql' ? current.dbHost : '',
|
||||
dbPort: value === 'mysql' || value === 'postgresql' ? current.dbPort || getDefaultPort(value) : 0,
|
||||
dbUser: value === 'mysql' || value === 'postgresql' ? current.dbUser : '',
|
||||
dbPassword: value === 'mysql' || value === 'postgresql' ? current.dbPassword : '',
|
||||
dbName: value === 'mysql' || value === 'postgresql' ? current.dbName : '',
|
||||
dbPath: value === 'sqlite' ? current.dbPath : '',
|
||||
}))
|
||||
if (value !== 'file') {
|
||||
setExcludePatternsText('')
|
||||
}
|
||||
}
|
||||
|
||||
function validate(value: BackupTaskPayload) {
|
||||
if (!value.name.trim()) {
|
||||
return '请输入任务名称'
|
||||
}
|
||||
if (!value.storageTargetId) {
|
||||
return '请选择存储目标'
|
||||
}
|
||||
if (value.cronExpr.trim() && value.cronExpr.trim().split(/\s+/).length < 5) {
|
||||
return 'Cron 表达式至少需要 5 段'
|
||||
}
|
||||
if (value.retentionDays < 0) {
|
||||
return '保留天数不能小于 0'
|
||||
}
|
||||
if (value.maxBackups < 0) {
|
||||
return '最大保留份数不能小于 0'
|
||||
}
|
||||
if (isFileBackupTask(value.type) && !value.sourcePath.trim()) {
|
||||
return '请输入源路径'
|
||||
}
|
||||
if (isSQLiteBackupTask(value.type) && !value.dbPath.trim()) {
|
||||
return '请输入 SQLite 数据库路径'
|
||||
}
|
||||
if (isDatabaseBackupTask(value.type)) {
|
||||
if (!value.dbHost.trim()) {
|
||||
return '请输入数据库主机'
|
||||
}
|
||||
if (!value.dbPort || value.dbPort <= 0) {
|
||||
return '请输入正确的数据库端口'
|
||||
}
|
||||
if (!value.dbUser.trim()) {
|
||||
return '请输入数据库用户名'
|
||||
}
|
||||
if (!initialValue?.maskedFields?.includes('dbPassword') && !value.dbPassword.trim()) {
|
||||
return '请输入数据库密码'
|
||||
}
|
||||
if (!value.dbName.trim()) {
|
||||
return '请输入数据库名称'
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const nextValue: BackupTaskPayload = {
|
||||
...draft,
|
||||
excludePatterns: excludePatternsText
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
}
|
||||
const validationError = validate(nextValue)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
await onSubmit(nextValue, initialValue?.id)
|
||||
}
|
||||
|
||||
function renderBasicStep() {
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Text>任务名称</Typography.Text>
|
||||
<Input value={draft.name} placeholder="例如:生产站点每日备份" onChange={(value) => updateDraft({ name: value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>备份类型</Typography.Text>
|
||||
<Select value={draft.type} options={backupTaskTypeOptions as unknown as { label: string; value: string }[]} onChange={(value) => updateTaskType(value as BackupTaskType)} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>Cron 表达式</Typography.Text>
|
||||
<CronInput value={draft.cronExpr} onChange={(value) => updateDraft({ cronExpr: value })} />
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
留空表示仅手动执行;已填写时由服务端调度器自动触发。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Space align="center" size="medium">
|
||||
<Typography.Text>启用任务</Typography.Text>
|
||||
<Switch checked={draft.enabled} onChange={(checked) => updateDraft({ enabled: checked })} />
|
||||
</Space>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
function renderSourceStep() {
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{isFileBackupTask(draft.type) ? (
|
||||
<>
|
||||
<div>
|
||||
<Typography.Text>源路径</Typography.Text>
|
||||
<Input value={draft.sourcePath} placeholder="例如:/var/www/html" onChange={(value) => updateDraft({ sourcePath: value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>排除规则</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={excludePatternsText}
|
||||
placeholder="每行一条,例如:node_modules\n*.log"
|
||||
autoSize={{ minRows: 4, maxRows: 8 }}
|
||||
onChange={(value) => setExcludePatternsText(value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isSQLiteBackupTask(draft.type) ? (
|
||||
<div>
|
||||
<Typography.Text>SQLite 数据库文件</Typography.Text>
|
||||
<Input value={draft.dbPath} placeholder="例如:/data/app.db" onChange={(value) => updateDraft({ dbPath: value })} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isDatabaseBackupTask(draft.type) ? (
|
||||
<>
|
||||
<div>
|
||||
<Typography.Text>数据库主机</Typography.Text>
|
||||
<Input value={draft.dbHost} placeholder="例如:127.0.0.1" onChange={(value) => updateDraft({ dbHost: value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>数据库端口</Typography.Text>
|
||||
<InputNumber style={{ width: '100%' }} value={draft.dbPort} min={1} onChange={(value) => updateDraft({ dbPort: Number(value ?? 0) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>数据库用户名</Typography.Text>
|
||||
<Input value={draft.dbUser} placeholder="例如:backup" onChange={(value) => updateDraft({ dbUser: value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>数据库密码</Typography.Text>
|
||||
<Input.Password value={draft.dbPassword} placeholder={initialValue?.maskedFields?.includes('dbPassword') ? '留空表示保持原密码' : '请输入数据库密码'} onChange={(value) => updateDraft({ dbPassword: value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>数据库名称</Typography.Text>
|
||||
<Input value={draft.dbName} placeholder="例如:app_prod" onChange={(value) => updateDraft({ dbName: value })} />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPolicyStep() {
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Text>存储目标</Typography.Text>
|
||||
<Select value={draft.storageTargetId || undefined} placeholder="请选择存储目标" options={storageTargetOptions} onChange={(value) => updateDraft({ storageTargetId: Number(value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>压缩策略</Typography.Text>
|
||||
<Select value={draft.compression} options={backupCompressionOptions as unknown as { label: string; value: string }[]} onChange={(value) => updateDraft({ compression: value as BackupTaskPayload['compression'] })} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>保留天数</Typography.Text>
|
||||
<InputNumber style={{ width: '100%' }} value={draft.retentionDays} min={0} onChange={(value) => updateDraft({ retentionDays: Number(value ?? 0) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>最大保留份数</Typography.Text>
|
||||
<InputNumber style={{ width: '100%' }} value={draft.maxBackups} min={0} onChange={(value) => updateDraft({ maxBackups: Number(value ?? 0) })} />
|
||||
</div>
|
||||
<Space align="center" size="medium">
|
||||
<Typography.Text>备份后加密</Typography.Text>
|
||||
<Switch checked={draft.encrypt} onChange={(checked) => updateDraft({ encrypt: checked })} />
|
||||
</Space>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width={640}
|
||||
title={initialValue ? '编辑备份任务' : '新建备份任务'}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
unmountOnExit={false}
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{error ? <Alert type="error" content={error} /> : <Alert type="info" content="配置数据库或文件的自动备份任务,系统将按策略执行并自动清理过期份数。" />}
|
||||
<Steps current={currentStep} size="small">
|
||||
<Steps.Step title="基础信息" />
|
||||
<Steps.Step title="源配置" />
|
||||
<Steps.Step title="存储与策略" />
|
||||
</Steps>
|
||||
<Divider style={{ margin: 0 }} />
|
||||
{currentStep === 0 ? renderBasicStep() : null}
|
||||
{currentStep === 1 ? renderSourceStep() : null}
|
||||
{currentStep === 2 ? renderPolicyStep() : null}
|
||||
<Space>
|
||||
<Button disabled={currentStep === 0} onClick={() => setCurrentStep((value) => Math.max(0, value - 1))}>
|
||||
上一步
|
||||
</Button>
|
||||
{currentStep < 2 ? (
|
||||
<Button type="outline" onClick={() => setCurrentStep((value) => Math.min(2, value + 1))}>
|
||||
下一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="primary" loading={loading} onClick={handleSubmit}>
|
||||
保存任务
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
32
web/src/components/backup-tasks/field-config.test.ts
Normal file
32
web/src/components/backup-tasks/field-config.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getBackupTaskStatusColor,
|
||||
getBackupTaskStatusLabel,
|
||||
getBackupTaskTypeLabel,
|
||||
getDefaultPort,
|
||||
isDatabaseBackupTask,
|
||||
isFileBackupTask,
|
||||
isSQLiteBackupTask,
|
||||
} from './field-config'
|
||||
|
||||
describe('backup task field config', () => {
|
||||
it('returns readable task labels', () => {
|
||||
expect(getBackupTaskTypeLabel('file')).toBe('文件目录')
|
||||
expect(getBackupTaskTypeLabel('postgresql')).toBe('PostgreSQL')
|
||||
})
|
||||
|
||||
it('classifies task types correctly', () => {
|
||||
expect(isFileBackupTask('file')).toBe(true)
|
||||
expect(isSQLiteBackupTask('sqlite')).toBe(true)
|
||||
expect(isDatabaseBackupTask('mysql')).toBe(true)
|
||||
expect(isDatabaseBackupTask('postgresql')).toBe(true)
|
||||
expect(isDatabaseBackupTask('file')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns expected status meta and default ports', () => {
|
||||
expect(getBackupTaskStatusLabel('success')).toBe('成功')
|
||||
expect(getBackupTaskStatusColor('failed')).toBe('red')
|
||||
expect(getDefaultPort('mysql')).toBe(3306)
|
||||
expect(getDefaultPort('postgresql')).toBe(5432)
|
||||
})
|
||||
})
|
||||
83
web/src/components/backup-tasks/field-config.ts
Normal file
83
web/src/components/backup-tasks/field-config.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { BackupCompression, BackupTaskStatus, BackupTaskType } from '../../types/backup-tasks'
|
||||
|
||||
export const backupTaskTypeOptions = [
|
||||
{ label: '文件目录', value: 'file' },
|
||||
{ label: 'MySQL', value: 'mysql' },
|
||||
{ label: 'SQLite', value: 'sqlite' },
|
||||
{ label: 'PostgreSQL', value: 'postgresql' },
|
||||
] as const
|
||||
|
||||
export const backupCompressionOptions = [
|
||||
{ label: 'Gzip 压缩', value: 'gzip' },
|
||||
{ label: '不压缩', value: 'none' },
|
||||
] as const
|
||||
|
||||
export function getBackupTaskTypeLabel(type: BackupTaskType) {
|
||||
switch (type) {
|
||||
case 'file':
|
||||
return '文件目录'
|
||||
case 'mysql':
|
||||
return 'MySQL'
|
||||
case 'sqlite':
|
||||
return 'SQLite'
|
||||
case 'postgresql':
|
||||
return 'PostgreSQL'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
export function getBackupTaskStatusLabel(status: BackupTaskStatus) {
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
return '空闲'
|
||||
case 'running':
|
||||
return '执行中'
|
||||
case 'success':
|
||||
return '成功'
|
||||
case 'failed':
|
||||
return '失败'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
export function getBackupTaskStatusColor(status: BackupTaskStatus) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'green'
|
||||
case 'failed':
|
||||
return 'red'
|
||||
case 'running':
|
||||
return 'arcoblue'
|
||||
default:
|
||||
return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
export function isFileBackupTask(type: BackupTaskType) {
|
||||
return type === 'file'
|
||||
}
|
||||
|
||||
export function isSQLiteBackupTask(type: BackupTaskType) {
|
||||
return type === 'sqlite'
|
||||
}
|
||||
|
||||
export function isDatabaseBackupTask(type: BackupTaskType) {
|
||||
return type === 'mysql' || type === 'postgresql'
|
||||
}
|
||||
|
||||
export function getDefaultPort(type: BackupTaskType) {
|
||||
switch (type) {
|
||||
case 'mysql':
|
||||
return 3306
|
||||
case 'postgresql':
|
||||
return 5432
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function getCompressionLabel(compression: BackupCompression) {
|
||||
return compression === 'gzip' ? 'Gzip' : '无'
|
||||
}
|
||||
184
web/src/components/notifications/NotificationFormDrawer.tsx
Normal file
184
web/src/components/notifications/NotificationFormDrawer.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Alert, Button, Drawer, Input, InputNumber, Select, Space, Switch, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { NotificationDetail, NotificationPayload, NotificationType } from '../../types/notifications'
|
||||
import { getNotificationFieldConfigs, getNotificationTypeLabel, notificationTypeOptions } from './field-config'
|
||||
|
||||
interface NotificationFormDrawerProps {
|
||||
visible: boolean
|
||||
loading: boolean
|
||||
testing: boolean
|
||||
initialValue: NotificationDetail | null
|
||||
onCancel: () => void
|
||||
onSubmit: (value: NotificationPayload, notificationId?: number) => Promise<void>
|
||||
onTest: (value: NotificationPayload, notificationId?: number) => Promise<void>
|
||||
}
|
||||
|
||||
function createEmptyDraft(): NotificationPayload {
|
||||
return {
|
||||
name: '',
|
||||
type: 'webhook',
|
||||
enabled: true,
|
||||
onSuccess: false,
|
||||
onFailure: true,
|
||||
config: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function NotificationFormDrawer({ visible, loading, testing, initialValue, onCancel, onSubmit, onTest }: NotificationFormDrawerProps) {
|
||||
const [draft, setDraft] = useState<NotificationPayload>(createEmptyDraft())
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
if (!initialValue) {
|
||||
setDraft(createEmptyDraft())
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
setDraft({
|
||||
name: initialValue.name,
|
||||
type: initialValue.type,
|
||||
enabled: initialValue.enabled,
|
||||
onSuccess: initialValue.onSuccess,
|
||||
onFailure: initialValue.onFailure,
|
||||
config: { ...initialValue.config },
|
||||
})
|
||||
setError('')
|
||||
}, [initialValue, visible])
|
||||
|
||||
const fieldConfigs = useMemo(() => getNotificationFieldConfigs(draft.type), [draft.type])
|
||||
|
||||
function updateDraft(patch: Partial<NotificationPayload>) {
|
||||
setDraft((current) => ({ ...current, ...patch }))
|
||||
}
|
||||
|
||||
function updateConfig(key: string, value: string | number) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
config: {
|
||||
...current.config,
|
||||
[key]: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function validate(value: NotificationPayload) {
|
||||
if (!value.name.trim()) {
|
||||
return '请输入通知名称'
|
||||
}
|
||||
for (const field of fieldConfigs) {
|
||||
if (!field.required) {
|
||||
continue
|
||||
}
|
||||
const currentValue = value.config[field.key]
|
||||
if (typeof currentValue === 'number' && currentValue > 0) {
|
||||
continue
|
||||
}
|
||||
if (typeof currentValue === 'string' && currentValue.trim()) {
|
||||
continue
|
||||
}
|
||||
if (initialValue?.maskedFields?.includes(field.key) && (currentValue === '' || currentValue === undefined)) {
|
||||
continue
|
||||
}
|
||||
return `请填写${field.label}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const validationError = validate(draft)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
await onSubmit(draft, initialValue?.id)
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
const validationError = validate(draft)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
await onTest(draft, initialValue?.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer width={560} title={initialValue ? '编辑通知配置' : '新建通知配置'} visible={visible} onCancel={onCancel} unmountOnExit={false}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{error ? <Alert type="error" content={error} /> : null}
|
||||
<div>
|
||||
<Typography.Text>名称</Typography.Text>
|
||||
<Input value={draft.name} placeholder="例如:生产故障通知" onChange={(value) => updateDraft({ name: value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>类型</Typography.Text>
|
||||
<Select value={draft.type} options={notificationTypeOptions as unknown as { label: string; value: string }[]} onChange={(value) => updateDraft({ type: value as NotificationType, config: {} })} />
|
||||
</div>
|
||||
<Space align="center" size="medium">
|
||||
<Typography.Text>启用</Typography.Text>
|
||||
<Switch checked={draft.enabled} onChange={(checked) => updateDraft({ enabled: checked })} />
|
||||
</Space>
|
||||
<Space align="center" size="medium">
|
||||
<Typography.Text>成功时通知</Typography.Text>
|
||||
<Switch checked={draft.onSuccess} onChange={(checked) => updateDraft({ onSuccess: checked })} />
|
||||
</Space>
|
||||
<Space align="center" size="medium">
|
||||
<Typography.Text>失败时通知</Typography.Text>
|
||||
<Switch checked={draft.onFailure} onChange={(checked) => updateDraft({ onFailure: checked })} />
|
||||
</Space>
|
||||
<div>
|
||||
<Typography.Title heading={6} style={{ marginTop: 0 }}>
|
||||
{getNotificationTypeLabel(draft.type)} 配置
|
||||
</Typography.Title>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{fieldConfigs.map((field) => {
|
||||
const currentValue = draft.config[field.key]
|
||||
const normalizedValue = typeof currentValue === 'number' || typeof currentValue === 'string' ? currentValue : field.type === 'number' ? 0 : ''
|
||||
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<Typography.Text>
|
||||
{field.label}
|
||||
{field.required ? ' *' : ''}
|
||||
</Typography.Text>
|
||||
{field.type === 'password' ? (
|
||||
<Input.Password value={String(normalizedValue)} placeholder={field.placeholder} onChange={(value) => updateConfig(field.key, value)} />
|
||||
) : field.type === 'number' ? (
|
||||
<InputNumber style={{ width: '100%' }} value={Number(normalizedValue)} min={0} onChange={(value) => updateConfig(field.key, Number(value ?? 0))} />
|
||||
) : field.type === 'textarea' ? (
|
||||
<Input.TextArea value={String(normalizedValue)} placeholder={field.placeholder} onChange={(value) => updateConfig(field.key, value)} />
|
||||
) : (
|
||||
<Input value={String(normalizedValue)} placeholder={field.placeholder} onChange={(value) => updateConfig(field.key, value)} />
|
||||
)}
|
||||
{field.description ? (
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
{field.description}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
{initialValue?.maskedFields?.includes(field.key) && !draft.config[field.key] ? (
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
已存在敏感配置,留空则保持不变。
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
<Space>
|
||||
<Button loading={testing} onClick={handleTest}>
|
||||
发送测试通知
|
||||
</Button>
|
||||
<Button type="primary" loading={loading} onClick={handleSubmit}>
|
||||
保存配置
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
19
web/src/components/notifications/field-config.test.ts
Normal file
19
web/src/components/notifications/field-config.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getNotificationFieldConfigs, getNotificationTypeLabel } from './field-config'
|
||||
|
||||
describe('notification field config', () => {
|
||||
it('returns readable type labels', () => {
|
||||
expect(getNotificationTypeLabel('email')).toBe('Email')
|
||||
expect(getNotificationTypeLabel('telegram')).toBe('Telegram')
|
||||
})
|
||||
|
||||
it('returns required fields for each notification type', () => {
|
||||
const emailFields = getNotificationFieldConfigs('email')
|
||||
const webhookFields = getNotificationFieldConfigs('webhook')
|
||||
const telegramFields = getNotificationFieldConfigs('telegram')
|
||||
|
||||
expect(emailFields.some((field) => field.key === 'host' && field.required)).toBe(true)
|
||||
expect(webhookFields.some((field) => field.key === 'url' && field.required)).toBe(true)
|
||||
expect(telegramFields.some((field) => field.key === 'botToken' && field.required)).toBe(true)
|
||||
})
|
||||
})
|
||||
43
web/src/components/notifications/field-config.ts
Normal file
43
web/src/components/notifications/field-config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { NotificationFieldConfig, NotificationType } from '../../types/notifications'
|
||||
|
||||
const FIELD_CONFIG_MAP: Record<NotificationType, NotificationFieldConfig[]> = {
|
||||
email: [
|
||||
{ key: 'host', label: 'SMTP Host', type: 'input', required: true, placeholder: 'smtp.example.com' },
|
||||
{ key: 'port', label: 'SMTP Port', type: 'number', required: true, placeholder: '587' },
|
||||
{ key: 'username', label: '用户名', type: 'input', placeholder: '可选' },
|
||||
{ key: 'password', label: '密码', type: 'password', placeholder: '留空表示保持原密码', sensitive: true },
|
||||
{ key: 'from', label: '发件人', type: 'input', required: true, placeholder: 'backupx@example.com' },
|
||||
{ key: 'to', label: '收件人', type: 'input', required: true, placeholder: 'ops@example.com,dev@example.com' },
|
||||
],
|
||||
webhook: [
|
||||
{ key: 'url', label: 'Webhook URL', type: 'input', required: true, placeholder: 'https://hooks.example.com/backupx' },
|
||||
{ key: 'secret', label: '共享密钥', type: 'password', placeholder: '可选', sensitive: true },
|
||||
],
|
||||
telegram: [
|
||||
{ key: 'botToken', label: 'Bot Token', type: 'password', required: true, placeholder: '123456:ABC', sensitive: true },
|
||||
{ key: 'chatId', label: 'Chat ID', type: 'input', required: true, placeholder: '-100xxxxxxxxxx' },
|
||||
],
|
||||
}
|
||||
|
||||
export const notificationTypeOptions = [
|
||||
{ label: 'Email', value: 'email' },
|
||||
{ label: 'Webhook', value: 'webhook' },
|
||||
{ label: 'Telegram', value: 'telegram' },
|
||||
] as const
|
||||
|
||||
export function getNotificationTypeLabel(type: NotificationType) {
|
||||
switch (type) {
|
||||
case 'email':
|
||||
return 'Email'
|
||||
case 'webhook':
|
||||
return 'Webhook'
|
||||
case 'telegram':
|
||||
return 'Telegram'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
export function getNotificationFieldConfigs(type: NotificationType) {
|
||||
return FIELD_CONFIG_MAP[type]
|
||||
}
|
||||
14
web/src/components/page-card.tsx
Normal file
14
web/src/components/page-card.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Card } from '@arco-design/web-react';
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
interface PageCardProps extends PropsWithChildren {
|
||||
title: ReactNode;
|
||||
}
|
||||
|
||||
export function PageCard({ title, children }: PageCardProps) {
|
||||
return (
|
||||
<Card className="page-card" title={title} bordered={false}>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
235
web/src/components/storage-targets/StorageTargetFormDrawer.tsx
Normal file
235
web/src/components/storage-targets/StorageTargetFormDrawer.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { Alert, Button, Divider, Drawer, Input, Select, Space, Switch, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { getStorageTargetFieldConfigs, getStorageTargetTypeLabel, storageTargetTypeOptions } from './field-config'
|
||||
import type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetType } from '../../types/storage-targets'
|
||||
|
||||
interface StorageTargetFormDrawerProps {
|
||||
visible: boolean
|
||||
loading: boolean
|
||||
testing: boolean
|
||||
initialValue: StorageTargetDetail | null
|
||||
onCancel: () => void
|
||||
onSubmit: (value: StorageTargetPayload, targetId?: number) => Promise<void>
|
||||
onTest: (value: StorageTargetPayload, targetId?: number) => Promise<StorageConnectionTestResult>
|
||||
onGoogleDriveAuth: (value: StorageTargetPayload, targetId?: number) => Promise<void>
|
||||
}
|
||||
|
||||
function createEmptyDraft(type: StorageTargetType = 'local_disk'): StorageTargetPayload {
|
||||
return {
|
||||
name: '',
|
||||
type,
|
||||
description: '',
|
||||
enabled: true,
|
||||
config: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function StorageTargetFormDrawer({
|
||||
visible,
|
||||
loading,
|
||||
testing,
|
||||
initialValue,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
onTest,
|
||||
onGoogleDriveAuth,
|
||||
}: StorageTargetFormDrawerProps) {
|
||||
const [draft, setDraft] = useState<StorageTargetPayload>(createEmptyDraft())
|
||||
const [error, setError] = useState('')
|
||||
const [testResult, setTestResult] = useState<StorageConnectionTestResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
if (!initialValue) {
|
||||
setDraft(createEmptyDraft())
|
||||
setError('')
|
||||
setTestResult(null)
|
||||
return
|
||||
}
|
||||
setDraft({
|
||||
name: initialValue.name,
|
||||
type: initialValue.type,
|
||||
description: initialValue.description,
|
||||
enabled: initialValue.enabled,
|
||||
config: { ...initialValue.config },
|
||||
})
|
||||
setError('')
|
||||
setTestResult(null)
|
||||
}, [initialValue, visible])
|
||||
|
||||
const fieldConfigs = useMemo(() => getStorageTargetFieldConfigs(draft.type), [draft.type])
|
||||
|
||||
function updateConfig(key: string, value: string | boolean) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
config: {
|
||||
...current.config,
|
||||
[key]: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function validate(value: StorageTargetPayload) {
|
||||
if (!value.name.trim()) {
|
||||
return '请输入存储目标名称'
|
||||
}
|
||||
for (const field of fieldConfigs) {
|
||||
if (!field.required) {
|
||||
continue
|
||||
}
|
||||
const currentValue = value.config[field.key]
|
||||
if (field.type === 'switch') {
|
||||
continue
|
||||
}
|
||||
if (typeof currentValue !== 'string' || !currentValue.trim()) {
|
||||
return `请填写${field.label}`
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const validationError = validate(draft)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
await onSubmit(draft, initialValue?.id)
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
const validationError = validate(draft)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
const result = await onTest(draft, initialValue?.id)
|
||||
setTestResult(result)
|
||||
}
|
||||
|
||||
async function handleGoogleDriveAuth() {
|
||||
const validationError = validate(draft)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
await onGoogleDriveAuth(draft, initialValue?.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width={560}
|
||||
title={initialValue ? '编辑存储目标' : '新建存储目标'}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
unmountOnExit={false}
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{error ? <Alert type="error" content={error} /> : <Alert type="info" content="存储目标提供备份文件的最终去向,请确保服务端网络连通性并通过测试。" />}
|
||||
{testResult ? <Alert type={testResult.success ? 'success' : 'warning'} content={testResult.message} /> : null}
|
||||
|
||||
<div>
|
||||
<Typography.Text>名称</Typography.Text>
|
||||
<Input value={draft.name} placeholder="例如:生产环境 MinIO" onChange={(value) => setDraft((current) => ({ ...current, name: value }))} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography.Text>类型</Typography.Text>
|
||||
<Select
|
||||
value={draft.type}
|
||||
options={storageTargetTypeOptions as unknown as { label: string; value: string }[]}
|
||||
onChange={(value) => {
|
||||
const nextType = value as StorageTargetType
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
type: nextType,
|
||||
config: {},
|
||||
}))
|
||||
setTestResult(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography.Text>描述</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={draft.description}
|
||||
placeholder="可选描述,例如备份上传到 NAS 或 Google Drive"
|
||||
onChange={(value) => setDraft((current) => ({ ...current, description: value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Space align="center" size="medium">
|
||||
<Typography.Text>启用</Typography.Text>
|
||||
<Switch checked={draft.enabled} onChange={(checked) => setDraft((current) => ({ ...current, enabled: checked }))} />
|
||||
</Space>
|
||||
|
||||
<Divider orientation="left">环境配置</Divider>
|
||||
|
||||
<div>
|
||||
<Typography.Title heading={6} style={{ marginTop: 0, color: 'var(--color-text-2)' }}>
|
||||
{getStorageTargetTypeLabel(draft.type)}
|
||||
</Typography.Title>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{fieldConfigs.map((field) => {
|
||||
const value = draft.config[field.key]
|
||||
const normalizedValue = typeof value === 'boolean' ? value : typeof value === 'string' ? value : field.type === 'switch' ? false : ''
|
||||
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<Typography.Text>
|
||||
{field.label}
|
||||
{field.required ? ' *' : ''}
|
||||
</Typography.Text>
|
||||
{field.type === 'switch' ? (
|
||||
<Space align="center" size="medium">
|
||||
<Switch checked={Boolean(normalizedValue)} onChange={(checked) => updateConfig(field.key, checked)} />
|
||||
{field.description ? <Typography.Text type="secondary">{field.description}</Typography.Text> : null}
|
||||
</Space>
|
||||
) : field.type === 'password' ? (
|
||||
<Input.Password
|
||||
value={String(normalizedValue)}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(nextValue) => updateConfig(field.key, nextValue)}
|
||||
/>
|
||||
) : (
|
||||
<Input value={String(normalizedValue)} placeholder={field.placeholder} onChange={(nextValue) => updateConfig(field.key, nextValue)} />
|
||||
)}
|
||||
{field.description && field.type !== 'switch' ? (
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
{field.description}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
{initialValue?.maskedFields?.includes(field.key) && !draft.config[field.key] ? (
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
已存在敏感配置,留空则保持不变。
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Button loading={testing} onClick={handleTest}>
|
||||
测试连接
|
||||
</Button>
|
||||
{draft.type === 'google_drive' ? (
|
||||
<Button type="outline" onClick={handleGoogleDriveAuth}>
|
||||
{initialValue ? '重新授权 Google Drive' : '发起 Google Drive 授权'}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" loading={loading} onClick={handleSubmit}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
15
web/src/components/storage-targets/field-config.test.ts
Normal file
15
web/src/components/storage-targets/field-config.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getStorageTargetFieldConfigs, getStorageTargetTypeLabel } from './field-config'
|
||||
|
||||
describe('storage target field config', () => {
|
||||
it('returns local disk field config', () => {
|
||||
const fields = getStorageTargetFieldConfigs('local_disk')
|
||||
expect(fields).toHaveLength(1)
|
||||
expect(fields[0]?.key).toBe('basePath')
|
||||
})
|
||||
|
||||
it('returns readable type labels', () => {
|
||||
expect(getStorageTargetTypeLabel('google_drive')).toBe('Google Drive')
|
||||
expect(getStorageTargetTypeLabel('webdav')).toBe('WebDAV')
|
||||
})
|
||||
})
|
||||
254
web/src/components/storage-targets/field-config.ts
Normal file
254
web/src/components/storage-targets/field-config.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import type { StorageTargetFieldConfig, StorageTargetType } from '../../types/storage-targets'
|
||||
|
||||
const FIELD_CONFIG_MAP: Record<StorageTargetType, StorageTargetFieldConfig[]> = {
|
||||
local_disk: [
|
||||
{
|
||||
key: 'basePath',
|
||||
label: '基础目录',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: '/data/backups',
|
||||
description: 'BackupX 将在该目录下创建和管理备份文件。',
|
||||
},
|
||||
],
|
||||
s3: [
|
||||
{
|
||||
key: 'endpoint',
|
||||
label: 'Endpoint',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: 'https://s3.amazonaws.com',
|
||||
},
|
||||
{
|
||||
key: 'region',
|
||||
label: '区域',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: 'ap-east-1',
|
||||
},
|
||||
{
|
||||
key: 'bucket',
|
||||
label: 'Bucket',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: 'backupx-prod',
|
||||
},
|
||||
{
|
||||
key: 'accessKeyId',
|
||||
label: 'Access Key ID',
|
||||
type: 'input',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
placeholder: 'AKIA...',
|
||||
},
|
||||
{
|
||||
key: 'secretAccessKey',
|
||||
label: 'Secret Access Key',
|
||||
type: 'password',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
placeholder: '输入新的 Secret Access Key',
|
||||
},
|
||||
{
|
||||
key: 'forcePathStyle',
|
||||
label: '强制 Path Style',
|
||||
type: 'switch',
|
||||
description: 'MinIO 或部分兼容对象存储通常需要开启。',
|
||||
},
|
||||
],
|
||||
webdav: [
|
||||
{
|
||||
key: 'endpoint',
|
||||
label: 'WebDAV 地址',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: 'https://dav.example.com/remote.php/dav/files/admin',
|
||||
},
|
||||
{
|
||||
key: 'username',
|
||||
label: '用户名',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: 'admin',
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: '密码',
|
||||
type: 'password',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
placeholder: '输入新的 WebDAV 密码',
|
||||
},
|
||||
{
|
||||
key: 'basePath',
|
||||
label: '基础目录',
|
||||
type: 'input',
|
||||
placeholder: '/backupx',
|
||||
},
|
||||
],
|
||||
google_drive: [
|
||||
{
|
||||
key: 'clientId',
|
||||
label: 'Client ID',
|
||||
type: 'input',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
placeholder: 'Google OAuth Client ID',
|
||||
},
|
||||
{
|
||||
key: 'clientSecret',
|
||||
label: 'Client Secret',
|
||||
type: 'password',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
placeholder: '输入新的 Google Client Secret',
|
||||
},
|
||||
{
|
||||
key: 'folderId',
|
||||
label: '目标文件夹 ID',
|
||||
type: 'input',
|
||||
placeholder: '留空则使用根目录',
|
||||
},
|
||||
],
|
||||
aliyun_oss: [
|
||||
{
|
||||
key: 'region',
|
||||
label: '区域 (Region)',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: 'cn-hangzhou',
|
||||
description: '如 cn-hangzhou, cn-shanghai, cn-beijing, cn-shenzhen 等。系统会自动组装 Endpoint。',
|
||||
},
|
||||
{
|
||||
key: 'bucket',
|
||||
label: 'Bucket',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: 'my-backup-bucket',
|
||||
},
|
||||
{
|
||||
key: 'accessKeyId',
|
||||
label: 'AccessKey ID',
|
||||
type: 'input',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
placeholder: 'LTAI...',
|
||||
},
|
||||
{
|
||||
key: 'secretAccessKey',
|
||||
label: 'AccessKey Secret',
|
||||
type: 'password',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
placeholder: '输入新的 AccessKey Secret',
|
||||
},
|
||||
{
|
||||
key: 'internalNetwork',
|
||||
label: '使用内网 Endpoint',
|
||||
type: 'switch',
|
||||
description: '同一区域的 ECS 实例可启用内网传输,节省流量费用。',
|
||||
},
|
||||
],
|
||||
tencent_cos: [
|
||||
{
|
||||
key: 'region',
|
||||
label: '区域 (Region)',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: 'ap-guangzhou',
|
||||
description: '如 ap-guangzhou, ap-shanghai, ap-beijing, ap-chengdu 等。系统会自动组装 Endpoint。',
|
||||
},
|
||||
{
|
||||
key: 'bucket',
|
||||
label: 'Bucket',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: 'backup-1250000000',
|
||||
description: '格式为 BucketName-APPID,如 backup-1250000000。',
|
||||
},
|
||||
{
|
||||
key: 'accessKeyId',
|
||||
label: 'SecretId',
|
||||
type: 'input',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
placeholder: 'AKIDxxxxxxxx',
|
||||
},
|
||||
{
|
||||
key: 'secretAccessKey',
|
||||
label: 'SecretKey',
|
||||
type: 'password',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
placeholder: '输入新的 SecretKey',
|
||||
},
|
||||
],
|
||||
qiniu_kodo: [
|
||||
{
|
||||
key: 'region',
|
||||
label: '区域 (Region)',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: 'z0',
|
||||
description: '支持 z0(华东), cn-east-2(华东-浙江2), z1(华北), z2(华南), na0(北美), as0(东南亚)。',
|
||||
},
|
||||
{
|
||||
key: 'bucket',
|
||||
label: 'Bucket',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: 'my-backup',
|
||||
},
|
||||
{
|
||||
key: 'accessKeyId',
|
||||
label: 'AccessKey',
|
||||
type: 'input',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
placeholder: '七牛云 AccessKey',
|
||||
},
|
||||
{
|
||||
key: 'secretAccessKey',
|
||||
label: 'SecretKey',
|
||||
type: 'password',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
placeholder: '输入新的 SecretKey',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export function getStorageTargetFieldConfigs(type: StorageTargetType) {
|
||||
return FIELD_CONFIG_MAP[type]
|
||||
}
|
||||
|
||||
export function getStorageTargetTypeLabel(type: StorageTargetType) {
|
||||
switch (type) {
|
||||
case 'local_disk':
|
||||
return '本地磁盘'
|
||||
case 'google_drive':
|
||||
return 'Google Drive'
|
||||
case 's3':
|
||||
return 'S3 Compatible'
|
||||
case 'webdav':
|
||||
return 'WebDAV'
|
||||
case 'aliyun_oss':
|
||||
return '阿里云 OSS'
|
||||
case 'tencent_cos':
|
||||
return '腾讯云 COS'
|
||||
case 'qiniu_kodo':
|
||||
return '七牛云 Kodo'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
export const storageTargetTypeOptions = [
|
||||
{ label: '本地磁盘', value: 'local_disk' },
|
||||
{ label: '阿里云 OSS', value: 'aliyun_oss' },
|
||||
{ label: '腾讯云 COS', value: 'tencent_cos' },
|
||||
{ label: '七牛云 Kodo', value: 'qiniu_kodo' },
|
||||
{ label: 'S3 Compatible', value: 's3' },
|
||||
{ label: 'Google Drive', value: 'google_drive' },
|
||||
{ label: 'WebDAV', value: 'webdav' },
|
||||
] as const
|
||||
20
web/src/i18n.ts
Normal file
20
web/src/i18n.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import zhCN from './locales/zh-CN.json'
|
||||
import enUS from './locales/en-US.json'
|
||||
|
||||
const savedLanguage = localStorage.getItem('backupx-language') || 'zh-CN'
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
'zh-CN': { translation: zhCN },
|
||||
'en-US': { translation: enUS },
|
||||
},
|
||||
lng: savedLanguage,
|
||||
fallbackLng: 'zh-CN',
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
170
web/src/layouts/AppLayout.tsx
Normal file
170
web/src/layouts/AppLayout.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Avatar, Button, Dropdown, Layout, Menu, Message, Modal, Form, Input, Space, Typography } from '@arco-design/web-react'
|
||||
import {
|
||||
IconDashboard,
|
||||
IconStorage,
|
||||
IconFile,
|
||||
IconHistory,
|
||||
IconNotification,
|
||||
IconSettings,
|
||||
IconMenuFold,
|
||||
IconMenuUnfold,
|
||||
IconLock,
|
||||
IconPoweroff,
|
||||
IconDown,
|
||||
IconCloud,
|
||||
IconDesktop,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import { useState } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { changePassword, type ChangePasswordPayload } from '../services/auth'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { resolveErrorMessage } from '../utils/error'
|
||||
|
||||
const Header = Layout.Header
|
||||
const Sider = Layout.Sider
|
||||
const Content = Layout.Content
|
||||
|
||||
function resolveSelectedKey(pathname: string) {
|
||||
if (pathname.startsWith('/backup/tasks')) {
|
||||
return '/backup/tasks'
|
||||
}
|
||||
if (pathname.startsWith('/backup/records')) {
|
||||
return '/backup/records'
|
||||
}
|
||||
if (pathname.startsWith('/storage-targets')) {
|
||||
return '/storage-targets'
|
||||
}
|
||||
if (pathname.startsWith('/settings/notifications')) {
|
||||
return '/settings/notifications'
|
||||
}
|
||||
if (pathname.startsWith('/nodes')) {
|
||||
return '/nodes'
|
||||
}
|
||||
if (pathname.startsWith('/settings') || pathname.startsWith('/system-info')) {
|
||||
return '/settings'
|
||||
}
|
||||
return pathname
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/dashboard', label: '仪表盘', icon: <IconDashboard /> },
|
||||
{ key: '/backup/tasks', label: '备份任务', icon: <IconFile /> },
|
||||
{ key: '/backup/records', label: '备份记录', icon: <IconHistory /> },
|
||||
{ key: '/storage-targets', label: '存储目标', icon: <IconStorage /> },
|
||||
{ key: '/nodes', label: '节点管理', icon: <IconDesktop /> },
|
||||
{ key: '/settings/notifications', label: '通知配置', icon: <IconNotification /> },
|
||||
{ key: '/settings', label: '系统设置', icon: <IconSettings /> },
|
||||
]
|
||||
|
||||
export function AppLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [pwdVisible, setPwdVisible] = useState(false)
|
||||
const [pwdLoading, setPwdLoading] = useState(false)
|
||||
const [pwdForm] = Form.useForm<ChangePasswordPayload & { confirmPassword: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const logout = useAuthStore((state) => state.logout)
|
||||
|
||||
async function handleChangePassword() {
|
||||
try {
|
||||
const values = await pwdForm.validate()
|
||||
if (values.newPassword !== values.confirmPassword) {
|
||||
Message.error('两次输入的新密码不一致')
|
||||
return
|
||||
}
|
||||
setPwdLoading(true)
|
||||
await changePassword({ oldPassword: values.oldPassword, newPassword: values.newPassword })
|
||||
Message.success('密码修改成功')
|
||||
setPwdVisible(false)
|
||||
pwdForm.resetFields()
|
||||
} catch (err) {
|
||||
if (err) {
|
||||
Message.error(resolveErrorMessage(err, '密码修改失败'))
|
||||
}
|
||||
} finally {
|
||||
setPwdLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const userDroplist = (
|
||||
<Menu onClickMenuItem={(key) => {
|
||||
if (key === 'password') {
|
||||
setPwdVisible(true)
|
||||
} else if (key === 'logout') {
|
||||
logout()
|
||||
}
|
||||
}}>
|
||||
<Menu.Item key="password"><IconLock style={{ marginRight: 8 }} />修改密码</Menu.Item>
|
||||
<Menu.Item key="logout"><IconPoweroff style={{ marginRight: 8 }} />退出登录</Menu.Item>
|
||||
</Menu>
|
||||
)
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider collapsible collapsed={collapsed} trigger={null} breakpoint="lg" width={220}>
|
||||
<div style={{ padding: '20px 16px', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<IconCloud style={{ fontSize: 28, color: 'var(--color-primary-6)' }} />
|
||||
{!collapsed && <Typography.Title heading={5} style={{ margin: 0, fontWeight: 700 }}>BackupX</Typography.Title>}
|
||||
</div>
|
||||
<Menu selectedKeys={[resolveSelectedKey(location.pathname)]} onClickMenuItem={(key) => navigate(key)}>
|
||||
{menuItems.map((item) => (
|
||||
<Menu.Item key={item.key}>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
{!collapsed && (
|
||||
<div style={{ position: 'absolute', bottom: 16, left: 0, right: 0, textAlign: 'center' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>v1.0.0</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 20px', background: 'var(--color-bg-2)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <IconMenuUnfold /> : <IconMenuFold />}
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
/>
|
||||
<Space>
|
||||
<Dropdown droplist={userDroplist} position="br">
|
||||
<Button type="text" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Avatar size={28} style={{ backgroundColor: 'var(--color-primary-6)' }}>
|
||||
{(user?.displayName ?? user?.username ?? '管')[0]}
|
||||
</Avatar>
|
||||
<span>{user?.displayName ?? user?.username ?? '管理员'}</span>
|
||||
<IconDown />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
<Content style={{ padding: '24px', background: 'var(--color-fill-2)', overflow: 'auto' }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
<Modal
|
||||
title="修改密码"
|
||||
visible={pwdVisible}
|
||||
onCancel={() => { setPwdVisible(false); pwdForm.resetFields() }}
|
||||
onOk={handleChangePassword}
|
||||
confirmLoading={pwdLoading}
|
||||
unmountOnExit
|
||||
>
|
||||
<Form form={pwdForm} layout="vertical">
|
||||
<Form.Item field="oldPassword" label="当前密码" rules={[{ required: true, minLength: 8 }]}>
|
||||
<Input.Password placeholder="请输入当前密码" />
|
||||
</Form.Item>
|
||||
<Form.Item field="newPassword" label="新密码" rules={[{ required: true, minLength: 8 }]}>
|
||||
<Input.Password placeholder="请输入新密码(至少 8 位)" />
|
||||
</Form.Item>
|
||||
<Form.Item field="confirmPassword" label="确认新密码" rules={[{ required: true, minLength: 8 }]}>
|
||||
<Input.Password placeholder="请再次输入新密码" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
84
web/src/layouts/protected-layout.tsx
Normal file
84
web/src/layouts/protected-layout.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Button,
|
||||
Layout,
|
||||
Menu,
|
||||
Space,
|
||||
Typography,
|
||||
} from '@arco-design/web-react';
|
||||
import {
|
||||
IconDashboard,
|
||||
IconInfoCircle,
|
||||
IconPoweroff,
|
||||
} from '@arco-design/web-react/icon';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const { Sider, Header, Content } = Layout;
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/',
|
||||
label: '仪表盘',
|
||||
icon: <IconDashboard />,
|
||||
},
|
||||
{
|
||||
key: '/system-info',
|
||||
label: '系统信息',
|
||||
icon: <IconInfoCircle />,
|
||||
},
|
||||
];
|
||||
|
||||
export function ProtectedLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider collapsible breakpoint="lg" className="app-sider">
|
||||
<div className="app-logo">BackupX</div>
|
||||
<Menu
|
||||
selectedKeys={[location.pathname]}
|
||||
onClickMenuItem={(key) => {
|
||||
navigate(key);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{menuItems.map((item) => (
|
||||
<Menu.Item key={item.key} data-testid={`menu-${item.key}`}>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 20px', background: 'var(--color-bg-2)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<Space size="large">
|
||||
<Typography.Title heading={6} style={{ margin: 0 }}>
|
||||
BackupX 管理台
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
{user?.displayName ?? user?.username ?? '未登录'}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
icon={<IconPoweroff />}
|
||||
type="outline"
|
||||
onClick={() => {
|
||||
logout();
|
||||
navigate('/login', { replace: true });
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</Button>
|
||||
</Space>
|
||||
</Header>
|
||||
<Content style={{ padding: '24px', background: 'var(--color-bg-1)', overflow: 'auto' }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
130
web/src/locales/en-US.json
Normal file
130
web/src/locales/en-US.json
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"common": {
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"create": "Create",
|
||||
"search": "Search",
|
||||
"loading": "Loading...",
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unknown": "Unknown",
|
||||
"noData": "No data",
|
||||
"actions": "Actions",
|
||||
"status": "Status",
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"description": "Description",
|
||||
"createdAt": "Created At",
|
||||
"updatedAt": "Updated At"
|
||||
},
|
||||
"menu": {
|
||||
"dashboard": "Dashboard",
|
||||
"backupTasks": "Backup Tasks",
|
||||
"backupRecords": "Backup Records",
|
||||
"storageTargets": "Storage",
|
||||
"notifications": "Notifications",
|
||||
"settings": "Settings",
|
||||
"docs": "Docs"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"changePassword": "Change Password",
|
||||
"oldPassword": "Old Password",
|
||||
"newPassword": "New Password",
|
||||
"loginTitle": "Sign in to BackupX",
|
||||
"loginSubtitle": "Linux Server Backup Manager"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"totalTasks": "Backup Tasks",
|
||||
"successRate": "Success Rate",
|
||||
"totalBackupSize": "Total Backup Size",
|
||||
"lastBackup": "Last Backup",
|
||||
"backupTimeline": "Backup Trend",
|
||||
"storageUsage": "Storage Usage",
|
||||
"recentRecords": "Recent Backups",
|
||||
"minutesAgo": "{{count}} min ago",
|
||||
"hoursAgo": "{{count}} hr ago",
|
||||
"daysAgo": "{{count}} days ago"
|
||||
},
|
||||
"backup": {
|
||||
"tasks": "Backup Tasks",
|
||||
"createTask": "Create Task",
|
||||
"editTask": "Edit Task",
|
||||
"taskName": "Task Name",
|
||||
"taskType": "Backup Type",
|
||||
"cronExpr": "Schedule",
|
||||
"sourcePath": "Source Path",
|
||||
"excludePatterns": "Exclude Patterns",
|
||||
"storageTarget": "Storage Target",
|
||||
"retentionDays": "Retention Days",
|
||||
"maxBackups": "Max Backups",
|
||||
"compression": "Compression",
|
||||
"encrypt": "Encryption",
|
||||
"runNow": "Run Now",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"typeFile": "File/Directory",
|
||||
"typeMySQL": "MySQL",
|
||||
"typeSQLite": "SQLite",
|
||||
"typePostgreSQL": "PostgreSQL"
|
||||
},
|
||||
"records": {
|
||||
"title": "Backup Records",
|
||||
"taskName": "Task Name",
|
||||
"status": "Status",
|
||||
"fileName": "File Name",
|
||||
"fileSize": "File Size",
|
||||
"duration": "Duration",
|
||||
"startedAt": "Started At",
|
||||
"viewLog": "View Log",
|
||||
"download": "Download",
|
||||
"restore": "Restore",
|
||||
"delete": "Delete",
|
||||
"statusRunning": "Running",
|
||||
"statusSuccess": "Success",
|
||||
"statusFailed": "Failed"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Storage",
|
||||
"addTarget": "Add Storage",
|
||||
"editTarget": "Edit Storage",
|
||||
"testConnection": "Test Connection",
|
||||
"connectionSuccess": "Connection Successful",
|
||||
"connectionFailed": "Connection Failed",
|
||||
"typeLocalDisk": "Local Disk",
|
||||
"typeGoogleDrive": "Google Drive",
|
||||
"typeS3": "S3 Compatible",
|
||||
"typeWebDAV": "WebDAV",
|
||||
"authorize": "Authorize",
|
||||
"authorized": "Authorized"
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notifications",
|
||||
"addNotification": "Add Notification",
|
||||
"editNotification": "Edit Notification",
|
||||
"testNotification": "Send Test",
|
||||
"typeEmail": "Email",
|
||||
"typeWebhook": "Webhook",
|
||||
"typeTelegram": "Telegram",
|
||||
"onSuccess": "Notify on Success",
|
||||
"onFailure": "Notify on Failure"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"general": "General",
|
||||
"language": "Language",
|
||||
"systemInfo": "System Info",
|
||||
"version": "Version",
|
||||
"uptime": "Uptime",
|
||||
"diskUsage": "Disk Usage"
|
||||
}
|
||||
}
|
||||
130
web/src/locales/zh-CN.json
Normal file
130
web/src/locales/zh-CN.json
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"common": {
|
||||
"confirm": "确认",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"create": "创建",
|
||||
"search": "搜索",
|
||||
"loading": "加载中...",
|
||||
"success": "操作成功",
|
||||
"failed": "操作失败",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"unknown": "未知",
|
||||
"noData": "暂无数据",
|
||||
"actions": "操作",
|
||||
"status": "状态",
|
||||
"name": "名称",
|
||||
"type": "类型",
|
||||
"description": "描述",
|
||||
"createdAt": "创建时间",
|
||||
"updatedAt": "更新时间"
|
||||
},
|
||||
"menu": {
|
||||
"dashboard": "仪表盘",
|
||||
"backupTasks": "备份任务",
|
||||
"backupRecords": "备份记录",
|
||||
"storageTargets": "存储管理",
|
||||
"notifications": "通知配置",
|
||||
"settings": "系统设置",
|
||||
"docs": "文档"
|
||||
},
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
"logout": "退出登录",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"changePassword": "修改密码",
|
||||
"oldPassword": "旧密码",
|
||||
"newPassword": "新密码",
|
||||
"loginTitle": "登录 BackupX",
|
||||
"loginSubtitle": "Linux 服务器备份管理系统"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "仪表盘",
|
||||
"totalTasks": "备份任务",
|
||||
"successRate": "成功率",
|
||||
"totalBackupSize": "总备份量",
|
||||
"lastBackup": "最近备份",
|
||||
"backupTimeline": "备份趋势",
|
||||
"storageUsage": "存储用量",
|
||||
"recentRecords": "最近备份记录",
|
||||
"minutesAgo": "{{count}} 分钟前",
|
||||
"hoursAgo": "{{count}} 小时前",
|
||||
"daysAgo": "{{count}} 天前"
|
||||
},
|
||||
"backup": {
|
||||
"tasks": "备份任务",
|
||||
"createTask": "创建任务",
|
||||
"editTask": "编辑任务",
|
||||
"taskName": "任务名称",
|
||||
"taskType": "备份类型",
|
||||
"cronExpr": "定时策略",
|
||||
"sourcePath": "源路径",
|
||||
"excludePatterns": "排除规则",
|
||||
"storageTarget": "存储目标",
|
||||
"retentionDays": "保留天数",
|
||||
"maxBackups": "最大份数",
|
||||
"compression": "压缩",
|
||||
"encrypt": "加密",
|
||||
"runNow": "立即执行",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"typeFile": "文件/目录",
|
||||
"typeMySQL": "MySQL",
|
||||
"typeSQLite": "SQLite",
|
||||
"typePostgreSQL": "PostgreSQL"
|
||||
},
|
||||
"records": {
|
||||
"title": "备份记录",
|
||||
"taskName": "任务名称",
|
||||
"status": "状态",
|
||||
"fileName": "文件名",
|
||||
"fileSize": "文件大小",
|
||||
"duration": "耗时",
|
||||
"startedAt": "开始时间",
|
||||
"viewLog": "查看日志",
|
||||
"download": "下载",
|
||||
"restore": "恢复",
|
||||
"delete": "删除",
|
||||
"statusRunning": "执行中",
|
||||
"statusSuccess": "成功",
|
||||
"statusFailed": "失败"
|
||||
},
|
||||
"storage": {
|
||||
"title": "存储管理",
|
||||
"addTarget": "添加存储",
|
||||
"editTarget": "编辑存储",
|
||||
"testConnection": "测试连接",
|
||||
"connectionSuccess": "连接成功",
|
||||
"connectionFailed": "连接失败",
|
||||
"typeLocalDisk": "本地磁盘",
|
||||
"typeGoogleDrive": "Google Drive",
|
||||
"typeS3": "S3 兼容",
|
||||
"typeWebDAV": "WebDAV",
|
||||
"authorize": "授权",
|
||||
"authorized": "已授权"
|
||||
},
|
||||
"notification": {
|
||||
"title": "通知配置",
|
||||
"addNotification": "添加通知",
|
||||
"editNotification": "编辑通知",
|
||||
"testNotification": "发送测试",
|
||||
"typeEmail": "邮件",
|
||||
"typeWebhook": "Webhook",
|
||||
"typeTelegram": "Telegram",
|
||||
"onSuccess": "成功时通知",
|
||||
"onFailure": "失败时通知"
|
||||
},
|
||||
"settings": {
|
||||
"title": "系统设置",
|
||||
"general": "通用设置",
|
||||
"language": "语言",
|
||||
"systemInfo": "系统信息",
|
||||
"version": "版本",
|
||||
"uptime": "运行时间",
|
||||
"diskUsage": "磁盘使用"
|
||||
}
|
||||
}
|
||||
12
web/src/main.tsx
Normal file
12
web/src/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import '@arco-design/web-react/dist/css/arco.css'
|
||||
import './styles/global.css'
|
||||
import './i18n'
|
||||
import { RootApp } from './RootApp'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<RootApp />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
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>
|
||||
);
|
||||
}
|
||||
61
web/src/router/ProtectedRoute.test.tsx
Normal file
61
web/src/router/ProtectedRoute.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
import { ProtectedRoute } from './ProtectedRoute'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
describe('ProtectedRoute', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
token: '',
|
||||
user: null,
|
||||
status: 'anonymous',
|
||||
bootstrapped: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects anonymous users to login', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>login page</div>} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div>dashboard page</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('login page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders protected content for authenticated users', () => {
|
||||
useAuthStore.setState({
|
||||
token: 'token',
|
||||
user: { id: 1, username: 'admin', displayName: 'Admin', role: 'admin' },
|
||||
status: 'authenticated',
|
||||
bootstrapped: true,
|
||||
})
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div>dashboard page</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('dashboard page')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
24
web/src/router/ProtectedRoute.tsx
Normal file
24
web/src/router/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { FullPageLoading } from '../components/FullPageLoading'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const status = useAuthStore((state) => state.status)
|
||||
const bootstrapped = useAuthStore((state) => state.bootstrapped)
|
||||
const location = useLocation()
|
||||
|
||||
if (!bootstrapped || status === 'loading') {
|
||||
return <FullPageLoading tip="正在验证登录状态..." />
|
||||
}
|
||||
|
||||
if (status !== 'authenticated') {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
40
web/src/router/index.tsx
Normal file
40
web/src/router/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AppLayout } from '../layouts/AppLayout'
|
||||
import { DashboardPage } from '../pages/dashboard/DashboardPage'
|
||||
import { LoginPage } from '../pages/login/LoginPage'
|
||||
import { NotificationsPage } from '../pages/notifications/NotificationsPage'
|
||||
import { BackupRecordsPage } from '../pages/backup-records/BackupRecordsPage'
|
||||
import { BackupTasksPage } from '../pages/backup-tasks/BackupTasksPage'
|
||||
import { GoogleDriveCallbackPage } from '../pages/storage-targets/GoogleDriveCallbackPage'
|
||||
import { StorageTargetsPage } from '../pages/storage-targets/StorageTargetsPage'
|
||||
import { SettingsPage } from '../pages/settings/SettingsPage'
|
||||
import NodesPage from '../pages/nodes/NodesPage'
|
||||
import { ProtectedRoute } from './ProtectedRoute'
|
||||
|
||||
export function RouterView() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="backup/tasks" element={<BackupTasksPage />} />
|
||||
<Route path="backup/records" element={<BackupRecordsPage />} />
|
||||
<Route path="storage-targets" element={<StorageTargetsPage />} />
|
||||
<Route path="storage-targets/google-drive/callback" element={<GoogleDriveCallbackPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="settings/notifications" element={<NotificationsPage />} />
|
||||
<Route path="nodes" element={<NodesPage />} />
|
||||
<Route path="system-info" element={<Navigate to="/settings" replace />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
59
web/src/services/auth.ts
Normal file
59
web/src/services/auth.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { http } from './http'
|
||||
|
||||
export interface SetupPayload {
|
||||
username: string
|
||||
password: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
username: string
|
||||
displayName: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
token: string
|
||||
user: UserInfo
|
||||
}
|
||||
|
||||
export async function fetchSetupStatus() {
|
||||
const response = await http.get<{ code: string; message: string; data: { initialized: boolean } }>('/auth/setup/status')
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function setup(payload: SetupPayload) {
|
||||
const response = await http.post<{ code: string; message: string; data: AuthResult }>('/auth/setup', payload)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function login(payload: LoginPayload) {
|
||||
const response = await http.post<{ code: string; message: string; data: AuthResult }>('/auth/login', payload)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function fetchProfile() {
|
||||
const response = await http.get<{ code: string; message: string; data: UserInfo }>('/auth/profile')
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export interface ChangePasswordPayload {
|
||||
oldPassword: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
export async function changePassword(payload: ChangePasswordPayload) {
|
||||
const response = await http.put<{ code: string; message: string; data: { changed: boolean } }>('/auth/password', payload)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const response = await http.post<{ code: string; message: string; data: { loggedOut: boolean } }>('/auth/logout')
|
||||
return response.data.data
|
||||
}
|
||||
155
web/src/services/backup-records.ts
Normal file
155
web/src/services/backup-records.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { http, getAccessToken, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||
import type { BackupLogEvent, BackupRecordDetail, BackupRecordListFilter, BackupRecordSummary } from '../types/backup-records'
|
||||
import { resolveErrorMessage } from '../utils/error'
|
||||
|
||||
interface RecordLogStreamHandlers {
|
||||
onEvent: (event: BackupLogEvent) => void
|
||||
onDone?: () => void
|
||||
onError?: (message: string) => void
|
||||
}
|
||||
|
||||
function buildRecordQuery(filter: BackupRecordListFilter) {
|
||||
const query: Record<string, string | number> = {}
|
||||
if (filter.taskId) {
|
||||
query.taskId = filter.taskId
|
||||
}
|
||||
if (filter.status) {
|
||||
query.status = filter.status
|
||||
}
|
||||
if (filter.dateFrom) {
|
||||
query.dateFrom = filter.dateFrom
|
||||
}
|
||||
if (filter.dateTo) {
|
||||
query.dateTo = filter.dateTo
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
function parseContentDisposition(value?: string) {
|
||||
if (!value) {
|
||||
return 'backup-artifact.bin'
|
||||
}
|
||||
const match = value.match(/filename="?([^";]+)"?/i)
|
||||
return match?.[1] ?? 'backup-artifact.bin'
|
||||
}
|
||||
|
||||
function parseLogEvent(chunk: string) {
|
||||
const payloadLine = chunk
|
||||
.split('\n')
|
||||
.find((line) => line.startsWith('data:'))
|
||||
|
||||
if (!payloadLine) {
|
||||
return null
|
||||
}
|
||||
|
||||
const payload = payloadLine.slice(5).trim()
|
||||
if (!payload) {
|
||||
return null
|
||||
}
|
||||
|
||||
return JSON.parse(payload) as BackupLogEvent
|
||||
}
|
||||
|
||||
async function resolveStreamError(response: Response) {
|
||||
try {
|
||||
const payload = (await response.json()) as { message?: string }
|
||||
return payload.message ?? '连接日志流失败'
|
||||
} catch {
|
||||
return `连接日志流失败(HTTP ${response.status})`
|
||||
}
|
||||
}
|
||||
|
||||
export async function listBackupRecords(filter: BackupRecordListFilter = {}) {
|
||||
const response = await http.get<ApiEnvelope<BackupRecordSummary[]>>('/backup/records', { params: buildRecordQuery(filter) })
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function getBackupRecord(id: number) {
|
||||
const response = await http.get<ApiEnvelope<BackupRecordDetail>>(`/backup/records/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function downloadBackupRecord(id: number) {
|
||||
const response = await http.get<Blob>(`/backup/records/${id}/download`, { responseType: 'blob' })
|
||||
return {
|
||||
blob: response.data,
|
||||
fileName: parseContentDisposition(response.headers['content-disposition']),
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreBackupRecord(id: number) {
|
||||
const response = await http.post<ApiEnvelope<{ restored: boolean }>>(`/backup/records/${id}/restore`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function deleteBackupRecord(id: number) {
|
||||
const response = await http.delete<ApiEnvelope<{ deleted: boolean }>>(`/backup/records/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export function streamBackupRecordLogs(recordId: number, handlers: RecordLogStreamHandlers) {
|
||||
const controller = new AbortController()
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const token = getAccessToken()
|
||||
const response = await fetch(`/api/backup/records/${recordId}/logs/stream`, {
|
||||
method: 'GET',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await resolveStreamError(response))
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('日志流不可用')
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n')
|
||||
const chunk = buffer.slice(0, boundary)
|
||||
buffer = buffer.slice(boundary + 2)
|
||||
|
||||
const event = parseLogEvent(chunk)
|
||||
if (!event) {
|
||||
continue
|
||||
}
|
||||
handlers.onEvent(event)
|
||||
if (event.completed) {
|
||||
handlers.onDone?.()
|
||||
controller.abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
const event = parseLogEvent(buffer)
|
||||
if (event) {
|
||||
handlers.onEvent(event)
|
||||
}
|
||||
}
|
||||
handlers.onDone?.()
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return
|
||||
}
|
||||
handlers.onError?.(resolveErrorMessage(error, '日志流连接失败'))
|
||||
}
|
||||
})()
|
||||
|
||||
return () => controller.abort()
|
||||
}
|
||||
38
web/src/services/backup-tasks.ts
Normal file
38
web/src/services/backup-tasks.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||
import type { BackupTaskDetail, BackupTaskPayload, BackupTaskSummary, BackupTaskTogglePayload } from '../types/backup-tasks'
|
||||
import type { BackupRecordDetail } from '../types/backup-records'
|
||||
|
||||
export async function listBackupTasks() {
|
||||
const response = await http.get<ApiEnvelope<BackupTaskSummary[]>>('/backup/tasks')
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function getBackupTask(id: number) {
|
||||
const response = await http.get<ApiEnvelope<BackupTaskDetail>>(`/backup/tasks/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function createBackupTask(payload: BackupTaskPayload) {
|
||||
const response = await http.post<ApiEnvelope<BackupTaskDetail>>('/backup/tasks', payload)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function updateBackupTask(id: number, payload: BackupTaskPayload) {
|
||||
const response = await http.put<ApiEnvelope<BackupTaskDetail>>(`/backup/tasks/${id}`, payload)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function deleteBackupTask(id: number) {
|
||||
const response = await http.delete<ApiEnvelope<{ deleted: boolean }>>(`/backup/tasks/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function toggleBackupTask(id: number, payload: BackupTaskTogglePayload) {
|
||||
const response = await http.put<ApiEnvelope<BackupTaskSummary>>(`/backup/tasks/${id}/toggle`, payload)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function runBackupTask(id: number) {
|
||||
const response = await http.post<ApiEnvelope<BackupRecordDetail>>(`/backup/tasks/${id}/run`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
12
web/src/services/dashboard.ts
Normal file
12
web/src/services/dashboard.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||
import type { BackupTimelinePoint, DashboardStats } from '../types/dashboard'
|
||||
|
||||
export async function fetchDashboardStats() {
|
||||
const response = await http.get<ApiEnvelope<DashboardStats>>('/dashboard/stats')
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function fetchDashboardTimeline(days = 30) {
|
||||
const response = await http.get<ApiEnvelope<BackupTimelinePoint[]>>('/dashboard/timeline', { params: { days } })
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
48
web/src/services/http.ts
Normal file
48
web/src/services/http.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
code: string | number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
let accessToken = ''
|
||||
let unauthorizedHandler: (() => void) | null = null
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
export function setAccessToken(token: string) {
|
||||
accessToken = token
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return accessToken
|
||||
}
|
||||
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||
unauthorizedHandler = handler
|
||||
}
|
||||
|
||||
export function unwrapApiEnvelope<T>(response: ApiEnvelope<T>) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && unauthorizedHandler) {
|
||||
unauthorizedHandler()
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
27
web/src/services/nodes.ts
Normal file
27
web/src/services/nodes.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||
import type { NodeSummary, DirEntry } from '../types/nodes'
|
||||
|
||||
export async function listNodes() {
|
||||
const response = await http.get<ApiEnvelope<NodeSummary[]>>('/nodes')
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function getNode(id: number) {
|
||||
const response = await http.get<ApiEnvelope<NodeSummary>>(`/nodes/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function createNode(name: string) {
|
||||
const response = await http.post<ApiEnvelope<{ token: string }>>('/nodes', { name })
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function deleteNode(id: number) {
|
||||
const response = await http.delete<ApiEnvelope<null>>(`/nodes/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function listNodeDirectory(nodeId: number, path: string) {
|
||||
const response = await http.get<ApiEnvelope<DirEntry[]>>(`/nodes/${nodeId}/fs/list`, { params: { path } })
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
37
web/src/services/notifications.ts
Normal file
37
web/src/services/notifications.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||
import type { NotificationDetail, NotificationPayload, NotificationSummary } from '../types/notifications'
|
||||
|
||||
export async function listNotifications() {
|
||||
const response = await http.get<ApiEnvelope<NotificationSummary[]>>('/notifications')
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function getNotification(id: number) {
|
||||
const response = await http.get<ApiEnvelope<NotificationDetail>>(`/notifications/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function createNotification(payload: NotificationPayload) {
|
||||
const response = await http.post<ApiEnvelope<NotificationDetail>>('/notifications', payload)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function updateNotification(id: number, payload: NotificationPayload) {
|
||||
const response = await http.put<ApiEnvelope<NotificationDetail>>(`/notifications/${id}`, payload)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function deleteNotification(id: number) {
|
||||
const response = await http.delete<ApiEnvelope<{ deleted: boolean }>>(`/notifications/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function testNotification(payload: NotificationPayload) {
|
||||
const response = await http.post<ApiEnvelope<{ success: boolean }>>('/notifications/test', payload, { timeout: 30000 })
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function testSavedNotification(id: number) {
|
||||
const response = await http.post<ApiEnvelope<{ success: boolean }>>(`/notifications/${id}/test`, undefined, { timeout: 30000 })
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
80
web/src/services/storage-targets.ts
Normal file
80
web/src/services/storage-targets.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { http } from './http'
|
||||
import type {
|
||||
GoogleDriveAuthStartResult,
|
||||
GoogleDriveCallbackResult,
|
||||
StorageConnectionTestResult,
|
||||
StorageTargetDetail,
|
||||
StorageTargetPayload,
|
||||
StorageTargetSummary,
|
||||
} from '../types/storage-targets'
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
code: string | number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
function unwrap<T>(response: ApiEnvelope<T>) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function listStorageTargets() {
|
||||
const response = await http.get<ApiEnvelope<StorageTargetSummary[]>>('/storage-targets')
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function getStorageTarget(id: number) {
|
||||
const response = await http.get<ApiEnvelope<StorageTargetDetail>>(`/storage-targets/${id}`)
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function createStorageTarget(payload: StorageTargetPayload) {
|
||||
const response = await http.post<ApiEnvelope<StorageTargetDetail>>('/storage-targets', payload)
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function updateStorageTarget(id: number, payload: StorageTargetPayload) {
|
||||
const response = await http.put<ApiEnvelope<StorageTargetDetail>>(`/storage-targets/${id}`, payload)
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function deleteStorageTarget(id: number) {
|
||||
const response = await http.delete<ApiEnvelope<{ deleted: boolean }>>(`/storage-targets/${id}`)
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function testStorageTarget(payload: StorageTargetPayload) {
|
||||
const response = await http.post<ApiEnvelope<StorageConnectionTestResult>>('/storage-targets/test', payload, { timeout: 30000 })
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function testSavedStorageTarget(id: number) {
|
||||
const response = await http.post<ApiEnvelope<StorageConnectionTestResult>>(`/storage-targets/${id}/test`, undefined, { timeout: 30000 })
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function startGoogleDriveAuth(payload: StorageTargetPayload, targetId?: number) {
|
||||
const response = await http.post<ApiEnvelope<GoogleDriveAuthStartResult>>('/storage-targets/google-drive/auth-url', {
|
||||
...payload,
|
||||
targetId,
|
||||
})
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function completeGoogleDriveAuth(queryString: string) {
|
||||
const suffix = queryString.startsWith('?') ? queryString : `?${queryString}`
|
||||
const response = await http.get<ApiEnvelope<GoogleDriveCallbackResult>>(`/storage-targets/google-drive/callback${suffix}`)
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export interface StorageTargetUsage {
|
||||
targetId: number
|
||||
targetName: string
|
||||
recordCount: number
|
||||
totalSize: number
|
||||
}
|
||||
|
||||
export async function getStorageTargetUsage(id: number) {
|
||||
const response = await http.get<ApiEnvelope<StorageTargetUsage>>(`/storage-targets/${id}/usage`)
|
||||
return unwrap(response.data)
|
||||
}
|
||||
27
web/src/services/system.ts
Normal file
27
web/src/services/system.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { http } from './http'
|
||||
|
||||
export interface SystemInfo {
|
||||
version: string
|
||||
mode: string
|
||||
startedAt: string
|
||||
uptimeSeconds: number
|
||||
databasePath: string
|
||||
diskTotal: number
|
||||
diskFree: number
|
||||
diskUsed: number
|
||||
}
|
||||
|
||||
export async function fetchSystemInfo() {
|
||||
const response = await http.get<{ code: string; message: string; data: SystemInfo }>('/system/info')
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function fetchSettings() {
|
||||
const response = await http.get<{ code: string; message: string; data: Record<string, string> }>('/settings')
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function updateSettings(settings: Record<string, string>) {
|
||||
const response = await http.put<{ code: string; message: string; data: Record<string, string> }>('/settings', settings)
|
||||
return response.data.data
|
||||
}
|
||||
52
web/src/stores/auth.test.ts
Normal file
52
web/src/stores/auth.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { authApi } from '../services/auth';
|
||||
import { useAuthStore } from './auth';
|
||||
|
||||
vi.mock('../services/auth', () => ({
|
||||
authApi: {
|
||||
login: vi.fn(),
|
||||
fetchProfile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
useAuthStore.setState({
|
||||
token: null,
|
||||
user: null,
|
||||
hydrated: true,
|
||||
status: 'idle',
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('stores token and user after login', async () => {
|
||||
vi.mocked(authApi.login).mockResolvedValue({
|
||||
token: 'jwt-token',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
displayName: '管理员',
|
||||
role: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
await useAuthStore.getState().login({ username: 'admin', password: 'secret' });
|
||||
|
||||
expect(useAuthStore.getState().token).toBe('jwt-token');
|
||||
expect(useAuthStore.getState().status).toBe('authenticated');
|
||||
expect(window.localStorage.getItem('backupx-auth-token')).toBe('jwt-token');
|
||||
});
|
||||
|
||||
it('clears state when bootstrap profile request fails', async () => {
|
||||
useAuthStore.setState({ token: 'expired-token', status: 'idle' });
|
||||
vi.mocked(authApi.fetchProfile).mockRejectedValue(new Error('unauthorized'));
|
||||
|
||||
await useAuthStore.getState().bootstrap();
|
||||
|
||||
expect(useAuthStore.getState().token).toBeNull();
|
||||
expect(useAuthStore.getState().status).toBe('anonymous');
|
||||
});
|
||||
});
|
||||
74
web/src/stores/auth.ts
Normal file
74
web/src/stores/auth.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { fetchProfile, login, setup, type LoginPayload, type SetupPayload, type UserInfo } from '../services/auth'
|
||||
import { setAccessToken, setUnauthorizedHandler } from '../services/http'
|
||||
|
||||
type AuthStatus = 'unknown' | 'loading' | 'anonymous' | 'authenticated'
|
||||
|
||||
interface AuthState {
|
||||
token: string
|
||||
user: UserInfo | null
|
||||
status: AuthStatus
|
||||
bootstrapped: boolean
|
||||
bootstrap: () => Promise<void>
|
||||
login: (payload: LoginPayload) => Promise<void>
|
||||
setup: (payload: SetupPayload) => Promise<void>
|
||||
logout: () => void
|
||||
applyAuth: (token: string, user: UserInfo) => void
|
||||
}
|
||||
|
||||
function clearAuthState(set: (partial: Partial<AuthState>) => void) {
|
||||
setAccessToken('')
|
||||
set({ token: '', user: null, status: 'anonymous', bootstrapped: true })
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
token: '',
|
||||
user: null,
|
||||
status: 'unknown',
|
||||
bootstrapped: false,
|
||||
bootstrap: async () => {
|
||||
const token = get().token
|
||||
setUnauthorizedHandler(() => {
|
||||
clearAuthState(set)
|
||||
})
|
||||
|
||||
if (!token) {
|
||||
setAccessToken('')
|
||||
set({ status: 'anonymous', bootstrapped: true })
|
||||
return
|
||||
}
|
||||
|
||||
setAccessToken(token)
|
||||
set({ status: 'loading' })
|
||||
try {
|
||||
const user = await fetchProfile()
|
||||
set({ user, status: 'authenticated', bootstrapped: true })
|
||||
} catch {
|
||||
clearAuthState(set)
|
||||
}
|
||||
},
|
||||
login: async (payload) => {
|
||||
const result = await login(payload)
|
||||
get().applyAuth(result.token, result.user)
|
||||
},
|
||||
setup: async (payload) => {
|
||||
const result = await setup(payload)
|
||||
get().applyAuth(result.token, result.user)
|
||||
},
|
||||
logout: () => {
|
||||
clearAuthState(set)
|
||||
},
|
||||
applyAuth: (token, user) => {
|
||||
setAccessToken(token)
|
||||
set({ token, user, status: 'authenticated', bootstrapped: true })
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'backupx-auth',
|
||||
partialize: (state) => ({ token: state.token }),
|
||||
},
|
||||
),
|
||||
)
|
||||
152
web/src/styles/global.css
Normal file
152
web/src/styles/global.css
Normal file
@@ -0,0 +1,152 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
color: #1d2129;
|
||||
background: #f2f3f5;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 1280px;
|
||||
}
|
||||
|
||||
/* ---- Card Overrides ---- */
|
||||
.arco-card {
|
||||
border: none !important;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.03) !important;
|
||||
}
|
||||
|
||||
/* ---- Login ---- */
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, #111a2c 0%, #1f2d47 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-bg::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(52,145,250,0.08) 0%, transparent 70%);
|
||||
top: -300px;
|
||||
right: -200px;
|
||||
}
|
||||
|
||||
.login-bg::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(114,46,209,0.06) 0%, transparent 70%);
|
||||
bottom: -200px;
|
||||
left: -100px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
width: 1000px;
|
||||
max-width: 90vw;
|
||||
min-height: 560px;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||
z-index: 1;
|
||||
animation: slideUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.login-banner {
|
||||
flex: 1;
|
||||
background: linear-gradient(135deg, var(--color-primary-6, #165dff) 0%, var(--color-primary-8, #0e42d2) 100%);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-banner-inner {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.login-form-wrapper {
|
||||
width: 440px;
|
||||
padding: 64px 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Full Page Shell ---- */
|
||||
.full-page-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ---- Log Viewer ---- */
|
||||
.log-viewer {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border-2);
|
||||
background: var(--color-bg-2); /* adapted for theme */
|
||||
color: var(--color-text-1);
|
||||
white-space: pre-wrap;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-2);
|
||||
border: 1px solid var(--color-border-2);
|
||||
color: var(--color-text-1);
|
||||
white-space: pre-wrap;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
}
|
||||
26
web/src/test/setup.ts
Normal file
26
web/src/test/setup.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
const storage = (() => {
|
||||
const store = new Map<string, string>()
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value)
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key)
|
||||
},
|
||||
clear: () => {
|
||||
store.clear()
|
||||
},
|
||||
key: (index: number) => Array.from(store.keys())[index] ?? null,
|
||||
get length() {
|
||||
return store.size
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: storage,
|
||||
configurable: true,
|
||||
})
|
||||
10
web/src/types/api.ts
Normal file
10
web/src/types/api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface ApiErrorPayload {
|
||||
code?: number;
|
||||
message?: string;
|
||||
}
|
||||
18
web/src/types/auth.ts
Normal file
18
web/src/types/auth.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
export type AuthStatus = 'idle' | 'bootstrapping' | 'authenticated' | 'anonymous';
|
||||
39
web/src/types/backup-records.ts
Normal file
39
web/src/types/backup-records.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export type BackupRecordStatus = 'running' | 'success' | 'failed'
|
||||
|
||||
export interface BackupLogEvent {
|
||||
recordId: number
|
||||
sequence: number
|
||||
level: string
|
||||
message: string
|
||||
timestamp: string
|
||||
completed: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface BackupRecordSummary {
|
||||
id: number
|
||||
taskId: number
|
||||
taskName: string
|
||||
storageTargetId: number
|
||||
storageTargetName: string
|
||||
status: BackupRecordStatus
|
||||
fileName: string
|
||||
fileSize: number
|
||||
storagePath: string
|
||||
durationSeconds: number
|
||||
errorMessage: string
|
||||
startedAt: string
|
||||
completedAt?: string
|
||||
}
|
||||
|
||||
export interface BackupRecordDetail extends BackupRecordSummary {
|
||||
logContent: string
|
||||
logEvents?: BackupLogEvent[]
|
||||
}
|
||||
|
||||
export interface BackupRecordListFilter {
|
||||
taskId?: number
|
||||
status?: BackupRecordStatus | ''
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
}
|
||||
61
web/src/types/backup-tasks.ts
Normal file
61
web/src/types/backup-tasks.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export type BackupTaskType = 'file' | 'mysql' | 'sqlite' | 'postgresql'
|
||||
export type BackupTaskStatus = 'idle' | 'running' | 'success' | 'failed'
|
||||
export type BackupCompression = 'gzip' | 'none'
|
||||
|
||||
export interface BackupTaskSummary {
|
||||
id: number
|
||||
name: string
|
||||
type: BackupTaskType
|
||||
enabled: boolean
|
||||
cronExpr: string
|
||||
storageTargetId: number
|
||||
storageTargetName: string
|
||||
nodeId: number
|
||||
nodeName?: string
|
||||
tags: string
|
||||
retentionDays: number
|
||||
compression: BackupCompression
|
||||
encrypt: boolean
|
||||
maxBackups: number
|
||||
lastRunAt?: string
|
||||
lastStatus: BackupTaskStatus
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface BackupTaskDetail extends BackupTaskSummary {
|
||||
sourcePath: string
|
||||
excludePatterns: string[]
|
||||
dbHost: string
|
||||
dbPort: number
|
||||
dbUser: string
|
||||
dbName: string
|
||||
dbPath: string
|
||||
maskedFields?: string[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface BackupTaskPayload {
|
||||
name: string
|
||||
type: BackupTaskType
|
||||
enabled: boolean
|
||||
cronExpr: string
|
||||
sourcePath: string
|
||||
excludePatterns: string[]
|
||||
dbHost: string
|
||||
dbPort: number
|
||||
dbUser: string
|
||||
dbPassword: string
|
||||
dbName: string
|
||||
dbPath: string
|
||||
storageTargetId: number
|
||||
nodeId: number
|
||||
tags: string
|
||||
retentionDays: number
|
||||
compression: BackupCompression
|
||||
encrypt: boolean
|
||||
maxBackups: number
|
||||
}
|
||||
|
||||
export interface BackupTaskTogglePayload {
|
||||
enabled?: boolean
|
||||
}
|
||||
25
web/src/types/dashboard.ts
Normal file
25
web/src/types/dashboard.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { BackupRecordSummary } from './backup-records'
|
||||
|
||||
export interface DashboardStorageUsageItem {
|
||||
storageTargetId: number
|
||||
targetName: string
|
||||
totalSize: number
|
||||
}
|
||||
|
||||
export interface BackupTimelinePoint {
|
||||
date: string
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
totalTasks: number
|
||||
enabledTasks: number
|
||||
totalRecords: number
|
||||
successRate: number
|
||||
totalBackupBytes: number
|
||||
lastBackupAt?: string
|
||||
recentRecords: BackupRecordSummary[]
|
||||
storageUsage: DashboardStorageUsageItem[]
|
||||
}
|
||||
20
web/src/types/nodes.ts
Normal file
20
web/src/types/nodes.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface NodeSummary {
|
||||
id: number
|
||||
name: string
|
||||
hostname: string
|
||||
ipAddress: string
|
||||
status: 'online' | 'offline'
|
||||
isLocal: boolean
|
||||
os: string
|
||||
arch: string
|
||||
agentVersion: string
|
||||
lastSeen: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface DirEntry {
|
||||
name: string
|
||||
path: string
|
||||
isDir: boolean
|
||||
size: number
|
||||
}
|
||||
36
web/src/types/notifications.ts
Normal file
36
web/src/types/notifications.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type NotificationType = 'email' | 'webhook' | 'telegram'
|
||||
export type NotificationFieldType = 'input' | 'password' | 'number' | 'textarea'
|
||||
|
||||
export interface NotificationSummary {
|
||||
id: number
|
||||
name: string
|
||||
type: NotificationType
|
||||
enabled: boolean
|
||||
onSuccess: boolean
|
||||
onFailure: boolean
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface NotificationDetail extends NotificationSummary {
|
||||
config: Record<string, string | number>
|
||||
maskedFields?: string[]
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
name: string
|
||||
type: NotificationType
|
||||
enabled: boolean
|
||||
onSuccess: boolean
|
||||
onFailure: boolean
|
||||
config: Record<string, string | number>
|
||||
}
|
||||
|
||||
export interface NotificationFieldConfig {
|
||||
key: string
|
||||
label: string
|
||||
type: NotificationFieldType
|
||||
required?: boolean
|
||||
placeholder?: string
|
||||
description?: string
|
||||
sensitive?: boolean
|
||||
}
|
||||
54
web/src/types/storage-targets.ts
Normal file
54
web/src/types/storage-targets.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export type StorageTargetType = 'local_disk' | 'google_drive' | 's3' | 'webdav' | 'aliyun_oss' | 'tencent_cos' | 'qiniu_kodo'
|
||||
export type StorageTestStatus = 'unknown' | 'success' | 'failed'
|
||||
export type StorageFieldType = 'input' | 'password' | 'switch'
|
||||
|
||||
export interface StorageTargetSummary {
|
||||
id: number
|
||||
name: string
|
||||
type: StorageTargetType
|
||||
description: string
|
||||
enabled: boolean
|
||||
updatedAt: string
|
||||
lastTestedAt?: string
|
||||
lastTestStatus: StorageTestStatus
|
||||
lastTestMessage?: string
|
||||
}
|
||||
|
||||
export interface StorageTargetDetail extends StorageTargetSummary {
|
||||
configVersion?: number
|
||||
config: Record<string, string | boolean>
|
||||
maskedFields?: string[]
|
||||
}
|
||||
|
||||
export interface StorageTargetPayload {
|
||||
name: string
|
||||
type: StorageTargetType
|
||||
description: string
|
||||
enabled: boolean
|
||||
config: Record<string, string | boolean>
|
||||
}
|
||||
|
||||
export interface StorageConnectionTestResult {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface GoogleDriveAuthStartResult {
|
||||
authUrl: string
|
||||
}
|
||||
|
||||
export interface GoogleDriveCallbackResult {
|
||||
success: boolean
|
||||
message: string
|
||||
target?: StorageTargetDetail
|
||||
}
|
||||
|
||||
export interface StorageTargetFieldConfig {
|
||||
key: string
|
||||
label: string
|
||||
type: StorageFieldType
|
||||
required?: boolean
|
||||
placeholder?: string
|
||||
description?: string
|
||||
sensitive?: boolean
|
||||
}
|
||||
7
web/src/types/system.ts
Normal file
7
web/src/types/system.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface SystemInfo {
|
||||
version: string;
|
||||
mode: string;
|
||||
startedAt: string;
|
||||
uptimeSeconds: number;
|
||||
databasePath: string;
|
||||
}
|
||||
11
web/src/utils/error.ts
Normal file
11
web/src/utils/error.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export function resolveErrorMessage(error: unknown, fallback = '请求失败,请稍后重试') {
|
||||
if (axios.isAxiosError(error)) {
|
||||
return error.response?.data?.message ?? fallback
|
||||
}
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
16
web/src/utils/format.test.ts
Normal file
16
web/src/utils/format.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatBytes, formatDuration, formatPercent } from './format'
|
||||
|
||||
describe('format utils', () => {
|
||||
it('formats bytes into readable units', () => {
|
||||
expect(formatBytes(0)).toBe('0 B')
|
||||
expect(formatBytes(1024)).toBe('1 KB')
|
||||
expect(formatBytes(1536)).toBe('1.5 KB')
|
||||
})
|
||||
|
||||
it('formats percent and duration', () => {
|
||||
expect(formatPercent(0.56)).toBe('56%')
|
||||
expect(formatDuration(45)).toBe('45 秒')
|
||||
expect(formatDuration(3661)).toBe('1 小时 1 分 1 秒')
|
||||
})
|
||||
})
|
||||
56
web/src/utils/format.ts
Normal file
56
web/src/utils/format.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export function formatDateTime(value?: string | Date | null) {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function formatBytes(value?: number | null) {
|
||||
if (!value || value <= 0) {
|
||||
return '0 B'
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let current = value
|
||||
let index = 0
|
||||
while (current >= 1024 && index < units.length - 1) {
|
||||
current /= 1024
|
||||
index += 1
|
||||
}
|
||||
const digits = current >= 10 || index === 0 ? 0 : 1
|
||||
const formatted = current.toFixed(digits).replace(/\.0$/, '')
|
||||
return `${formatted} ${units[index]}`
|
||||
}
|
||||
|
||||
export function formatPercent(value?: number | null) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return '0%'
|
||||
}
|
||||
return `${(value * 100).toFixed(value >= 0.1 ? 0 : 1)}%`
|
||||
}
|
||||
|
||||
export function formatDuration(seconds?: number | null) {
|
||||
if (!seconds || seconds <= 0) {
|
||||
return '0 秒'
|
||||
}
|
||||
if (seconds < 60) {
|
||||
return `${seconds} 秒`
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const remainSeconds = seconds % 60
|
||||
if (hours > 0) {
|
||||
return `${hours} 小时 ${minutes} 分 ${remainSeconds} 秒`
|
||||
}
|
||||
return `${minutes} 分 ${remainSeconds} 秒`
|
||||
}
|
||||
34
web/tsconfig.json
Normal file
34
web/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/app.tsx",
|
||||
"src/components/auth-guard.tsx",
|
||||
"src/components/page-card.tsx",
|
||||
"src/layouts/protected-layout.tsx",
|
||||
"src/pages/dashboard/page.tsx",
|
||||
"src/pages/login/page.tsx",
|
||||
"src/pages/system-info/page.tsx",
|
||||
"src/stores/auth.test.ts"
|
||||
],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
12
web/tsconfig.node.json
Normal file
12
web/tsconfig.node.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
web/tsconfig.node.tsbuildinfo
Normal file
1
web/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
web/tsconfig.tsbuildinfo
Normal file
1
web/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/rootapp.tsx","./src/main.tsx","./src/components/authbootstrap.tsx","./src/components/fullpageloading.tsx","./src/layouts/applayout.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/system-info/systeminfopage.tsx","./src/router/protectedroute.tsx","./src/router/index.tsx","./src/services/auth.ts","./src/services/http.ts","./src/services/system.ts","./src/stores/auth.ts","./src/test/setup.ts","./src/types/api.ts","./src/types/auth.ts","./src/types/system.ts"],"version":"5.9.3"}
|
||||
2
web/vite.config.d.ts
vendored
Normal file
2
web/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
32
web/vite.config.js
Normal file
32
web/vite.config.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8340',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
||||
'vendor-arco': ['@arco-design/web-react'],
|
||||
'vendor-echarts': ['echarts', 'echarts-for-react'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
globals: true,
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||
exclude: ['src/components/auth-guard.test.tsx', 'src/stores/auth.test.ts'],
|
||||
},
|
||||
});
|
||||
34
web/vite.config.ts
Normal file
34
web/vite.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8340',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
||||
'vendor-arco': ['@arco-design/web-react'],
|
||||
'vendor-echarts': ['echarts', 'echarts-for-react'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
globals: true,
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||
exclude: ['src/components/auth-guard.test.tsx', 'src/stores/auth.test.ts'],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user