feat: 解决 CDC 去重、集中备份与首次初始化问题 (#105)

实现 CDC 内容寻址仓库、远程 Agent 中央中转备份与首次初始化体验,并补充安全校验、测试及双语文档。

Closes #94
Closes #101
Closes #104
This commit is contained in:
Wu Qing
2026-08-07 23:01:29 +08:00
committed by GitHub
parent 50ce6587d8
commit 00151e466c
74 changed files with 4507 additions and 400 deletions

View File

@@ -280,6 +280,10 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
{ label: '文件名', value: record.fileName || '-' },
{ label: '文件大小', value: formatBytes(record.fileSize) },
{ label: '存储路径', value: record.storagePath || '-' },
...(record.storageTransferMode ? [{
label: '传输路径',
value: record.storageTransferMode === 'master_relay' ? 'Master 流式中转' : 'Agent 直传',
}] : []),
{ label: '开始时间', value: formatDateTime(record.startedAt) },
{ label: '完成时间', value: formatDateTime(record.completedAt) },
{ label: '耗时', value: formatDuration(record.durationSeconds) },
@@ -316,14 +320,16 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
</Button>
)}
</Space>
{record.storageUploadResults && record.storageUploadResults.length > 1 && (
{record.storageUploadResults && (record.storageUploadResults.length > 1 || record.storageUploadResults.some((result) => result.transferMode)) && (
<div>
<Typography.Title heading={6}></Typography.Title>
<Descriptions
column={1}
data={record.storageUploadResults.map((r: StorageUploadResultItem) => ({
label: r.storageTargetName,
value: r.status === 'success' ? '上传成功' : `上传失败: ${r.error || '未知错误'}`,
value: r.status === 'success'
? `上传成功${r.transferMode === 'master_relay' ? ' · Master 流式中转' : r.transferMode === 'direct' ? ' · Agent 直传' : ''}`
: `上传失败: ${r.error || '未知错误'}`,
}))}
/>
</div>

View File

@@ -1,5 +1,5 @@
import { Alert, Button, Divider, Drawer, Input, InputNumber, Select, Space, Steps, Switch, Typography, Grid } from '@arco-design/web-react'
import { IconDelete, IconPlus } from '@arco-design/web-react/icon'
import { IconDelete, IconPlus } from '../icons'
import { useEffect, useMemo, useState } from 'react'
import { CronInput } from '../CronInput'
import type { StorageTargetDetail, StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
@@ -9,6 +9,8 @@ import type { NodeSummary } from '../../types/nodes'
import { DatabasePicker } from '../common/DatabasePicker'
import { DirectoryPicker } from '../common/DirectoryPicker'
import { StorageTargetFormDrawer } from '../storage-targets/StorageTargetFormDrawer'
import { StorageTargetName } from '../storage-targets/StorageTargetName'
import { SourceServerSelector } from './SourceServerSelector'
import {
backupCompressionOptions,
backupTaskTypeOptions,
@@ -168,7 +170,7 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
return 0
})
return sorted.map((item) => ({
label: item.starred ? `${item.name}` : item.name,
label: <StorageTargetName name={item.name} starred={item.starred} />,
value: item.id,
disabled: !item.enabled,
}))
@@ -176,21 +178,6 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
[storageTargets],
)
// 执行节点选项:本地节点显示 "本机 (local)",远程节点带状态后缀
const nodeOptions = useMemo(() => {
const list = nodes ?? []
return [
{ label: '本机 (Master)', value: 0 },
...list
.filter((item) => !item.isLocal)
.map((item) => ({
label: `${item.name}${item.status === 'online' ? '' : '(离线)'}`,
value: item.id,
disabled: item.status !== 'online',
})),
]
}, [nodes])
function updateDraft(patch: Partial<BackupTaskPayload>) {
setDraft((current) => ({ ...current, ...patch }))
}
@@ -251,6 +238,12 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
if (validPaths.length === 0 && !value.sourcePath.trim()) {
return '请输入至少一个源路径'
}
if (value.backupMode === 'repository' && (((value.nodeId ?? 0) > 0 && value.nodeId !== localNodeId) || value.nodePoolTag?.trim())) {
return 'CDC 仓库模式当前仅支持 Master 本机执行'
}
if (value.backupMode === 'repository' && value.replicationTargetIds.length > 0) {
return 'CDC 仓库模式请直接多选存储目标,不能使用对象级副本复制'
}
}
if (isSQLiteBackupTask(value.type) && !value.dbPath.trim()) {
return '请输入 SQLite 数据库路径'
@@ -306,33 +299,26 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
<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></Typography.Text>
<Select
value={draft.nodeId ?? 0}
options={nodeOptions}
onChange={(value) => {
const nodeId = Number(value ?? 0)
// 固定节点与节点池互斥:切到固定节点时清空 NodePoolTag
updateDraft(nodeId > 0 ? { nodeId, nodePoolTag: '' } : { nodeId })
}}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
/"节点管理" Agent
</Typography.Paragraph>
</div>
<div>
<Typography.Text></Typography.Text>
<Input
placeholder="填写标签后从节点池动态调度(与固定节点互斥)"
value={draft.nodePoolTag ?? ''}
disabled={(draft.nodeId ?? 0) > 0}
onChange={(value) => updateDraft({ nodePoolTag: value })}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
"本机 / 未指定" Labels tag 线
</Typography.Paragraph>
</div>
<SourceServerSelector
nodeId={draft.nodeId ?? 0}
nodePoolTag={draft.nodePoolTag ?? ''}
localNodeId={localNodeId}
nodes={nodes}
onNodeChange={(nodeId) => {
// 固定源服务器与服务器池互斥CDC 仓库仍固定在 Master 单写者。
updateDraft(nodeId > 0
? {
nodeId,
nodePoolTag: '',
backupMode: nodeId !== localNodeId && draft.backupMode === 'repository' ? 'full' : draft.backupMode,
}
: { nodeId })
}}
onNodePoolTagChange={(value) => updateDraft({
nodePoolTag: value,
backupMode: value.trim() && draft.backupMode === 'repository' ? 'full' : draft.backupMode,
})}
/>
<div>
<Typography.Text>Cron </Typography.Text>
<CronInput value={draft.cronExpr} onChange={(value) => updateDraft({ cronExpr: value })} />
@@ -587,6 +573,11 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
</Button>
)}
</Space>
{((draft.nodeId ?? 0) > 0 && draft.nodeId !== localNodeId) || draft.nodePoolTag?.trim() ? (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
S3WebDAV Master Agent API Master HTTPS
</Typography.Paragraph>
) : null}
</div>
<div>
<Typography.Text></Typography.Text>
@@ -600,8 +591,11 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
options={[
{ label: '全量备份', value: 'full' },
{ label: '差异备份(仅文件、本机)', value: 'differential' },
{ label: 'CDC 去重仓库(仅文件、本机)', value: 'repository' },
]}
onChange={(value) => updateDraft({ backupMode: value as BackupMode })}
onChange={(value) => updateDraft(value === 'repository'
? { backupMode: value as BackupMode, nodeId: 0, nodePoolTag: '', replicationTargetIds: [] }
: { backupMode: value as BackupMode })}
/>
{draft.backupMode === 'differential' && (
<div style={{ marginTop: 8 }}>
@@ -618,6 +612,11 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
/>
</div>
)}
{draft.backupMode === 'repository' && (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 8 }}>
pack Master
</Typography.Paragraph>
)}
</div>
)}
<div>
@@ -736,10 +735,13 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
value={draft.replicationTargetIds}
placeholder="选择副本目标(不选 = 不启用复制)"
options={storageTargetOptions.filter((opt) => !(draft.storageTargetIds ?? []).includes(opt.value as number))}
disabled={draft.backupMode === 'repository'}
onChange={(values: number[]) => updateDraft({ replicationTargetIds: values })}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
3-2-1 2 1 provider
{draft.backupMode === 'repository'
? 'CDC 仓库包含共享 pack 与索引,不能只复制单个快照对象;请在“存储目标”中直接多选以生成完整仓库副本。'
: '备份成功后自动镜像到副本存储。满足 3-2-1 规则:至少 2 份副本、至少 1 份异地。建议选不同 provider 的目标。'}
</Typography.Paragraph>
</div>

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import type { NodeSummary } from '../../types/nodes'
import { buildSourceServerOptions } from './SourceServerSelector'
function node(id: number, name: string, status: NodeSummary['status'], isLocal = false): NodeSummary {
return {
id,
name,
status,
isLocal,
hostname: '',
ipAddress: '',
os: '',
arch: '',
agentVersion: '',
lastSeen: '',
createdAt: '',
}
}
describe('buildSourceServerOptions', () => {
it('keeps Master first and disables offline remote sources', () => {
const options = buildSourceServerOptions([
node(1, 'local', 'online', true),
node(2, 'source-b', 'online'),
node(3, 'source-c', 'offline'),
])
expect(options).toEqual([
{ label: 'Master 本机', value: 0, disabled: false },
{ label: 'source-b', value: 2, disabled: false },
{ label: 'source-c离线', value: 3, disabled: true },
])
})
})

View File

@@ -0,0 +1,57 @@
import { Input, Select, Typography } from '@arco-design/web-react'
import { useMemo } from 'react'
import type { NodeSummary } from '../../types/nodes'
interface SourceServerSelectorProps {
nodeId: number
nodePoolTag: string
localNodeId?: number
nodes?: NodeSummary[]
onNodeChange: (nodeId: number) => void
onNodePoolTagChange: (tag: string) => void
}
export function buildSourceServerOptions(nodes: NodeSummary[] = []) {
return [
{ label: 'Master 本机', value: 0, disabled: false },
...nodes
.filter((node) => !node.isLocal)
.map((node) => ({
label: `${node.name}${node.status === 'online' ? '' : '(离线)'}`,
value: node.id,
disabled: node.status !== 'online',
})),
]
}
export function SourceServerSelector({ nodeId, nodePoolTag, localNodeId, nodes, onNodeChange, onNodePoolTagChange }: SourceServerSelectorProps) {
const options = useMemo(() => buildSourceServerOptions(nodes), [nodes])
const selectedNode = nodes?.find((node) => node.id === nodeId)
const isRemote = nodeId > 0 && nodeId !== localNodeId
return (
<>
<div>
<Typography.Text></Typography.Text>
<Select value={nodeId} options={options} onChange={(value) => onNodeChange(Number(value ?? 0))} />
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
{isRemote
? `源路径与数据库在 ${selectedNode?.name ?? '远程服务器'} 上解析,由 Agent 就地生成备份。网络存储由 Agent 直传;启用 Master 中转的本地磁盘目标会通过认证连接写入中央目录。`
: '源路径与数据库在 Master 本机解析。要集中备份其他服务器,请先在“节点管理”安装 Agent再在这里选择对应源服务器。'}
</Typography.Paragraph>
</div>
<div>
<Typography.Text></Typography.Text>
<Input
placeholder="按标签从在线源服务器中动态选择(与固定源服务器互斥)"
value={nodePoolTag}
disabled={nodeId > 0}
onChange={onNodePoolTagChange}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
Master Labels 线 Agent
</Typography.Paragraph>
</div>
</>
)
}

View File

@@ -1,5 +1,5 @@
import { Button, Input, Message, Modal, Space, Spin, Tree, Typography, Empty } from '@arco-design/web-react'
import { IconFolder, IconFile, IconFolderAdd } from '@arco-design/web-react/icon'
import { IconFolder, IconFile, IconFolderAdd } from '../icons'
import { useCallback, useState } from 'react'
import { listNodeDirectory } from '../../services/nodes'
import type { DirEntry } from '../../types/nodes'

View File

@@ -1,5 +1,5 @@
import { Badge, Button, Drawer, Empty, Notification, Space, Tag, Typography } from '@arco-design/web-react'
import { IconNotification } from '@arco-design/web-react/icon'
import { IconNotification } from '../icons'
import { useEffect, useState } from 'react'
import { useEventStream, type SystemEvent } from '../../hooks/useEventStream'
import { useEventStore } from '../../stores/events'

View File

@@ -1,5 +1,5 @@
import { Empty, Input, Modal, Space, Spin, Tag, Typography } from '@arco-design/web-react'
import { IconSearch } from '@arco-design/web-react/icon'
import { IconSearch } from '../icons'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { globalSearch, type SearchKind, type SearchResult, type SearchResultItem } from '../../services/search'

View File

@@ -0,0 +1,18 @@
import { Select } from '@arco-design/web-react'
import { useTranslation } from 'react-i18next'
import { languageOptions, normalizeLanguage, setApplicationLanguage, type SupportedLanguage } from '../../i18n'
export function LanguageSwitcher() {
const { t, i18n } = useTranslation()
const currentLanguage = normalizeLanguage(i18n.resolvedLanguage)
return (
<Select
aria-label={t('auth.language')}
value={currentLanguage}
options={languageOptions}
style={{ width: 120 }}
onChange={(value) => void setApplicationLanguage(value as SupportedLanguage)}
/>
)
}

View File

@@ -0,0 +1,50 @@
import type { SVGProps } from 'react'
export function BackupServerIllustration(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="320"
height="320"
viewBox="0 0 320 320"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable="false"
{...props}
>
<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" />
<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" />
<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" />
<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" />
<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>
<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>
)
}

View File

@@ -0,0 +1,38 @@
export {
IconBook,
IconCheckCircle,
IconCloud,
IconCloudDownload,
IconCommand,
IconCopy,
IconDashboard,
IconDelete,
IconDesktop,
IconDown,
IconDownload,
IconEdit,
IconFile,
IconFilePdf,
IconFolder,
IconFolderAdd,
IconHistory,
IconInfoCircle,
IconList,
IconLock,
IconMenuFold,
IconMenuUnfold,
IconMore,
IconNotification,
IconPlus,
IconPoweroff,
IconRefresh,
IconSafe,
IconSave,
IconSearch,
IconSettings,
IconStarFill,
IconStorage,
IconUser,
} from '@arco-design/web-react/icon'
export { BackupServerIllustration } from './BackupServerIllustration'

View File

@@ -16,7 +16,14 @@ interface StorageTargetFormDrawerProps {
}
function createEmptyDraft(type: StorageTargetType = 'local_disk'): StorageTargetPayload {
return { name: '', type, description: '', enabled: true, config: {}, quotaBytes: 0 }
return {
name: '',
type,
description: '',
enabled: true,
config: type === 'local_disk' ? { masterRelay: true } : {},
quotaBytes: 0,
}
}
export function StorageTargetFormDrawer({
@@ -207,7 +214,8 @@ export function StorageTargetFormDrawer({
return label.toLowerCase().includes(input.toLowerCase())
}}
onChange={(value) => {
setDraft((c) => ({ ...c, type: value as string, config: {} }))
const config: StorageTargetPayload['config'] = value === 'local_disk' ? { masterRelay: true } : {}
setDraft((c) => ({ ...c, type: value as string, config }))
setTestResult(null)
}}
>

View File

@@ -0,0 +1,14 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { StorageTargetName } from './StorageTargetName'
describe('StorageTargetName', () => {
it('uses an SVG icon for starred targets without character symbols', () => {
const { container } = render(<StorageTargetName name="Central storage" starred />)
expect(screen.getByText('Central storage')).toBeInTheDocument()
expect(screen.getByLabelText('Central storage已收藏')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeNull()
expect(container.textContent).not.toContain(String.fromCodePoint(0x2605))
})
})

View File

@@ -0,0 +1,18 @@
import { IconStarFill } from '../icons'
interface StorageTargetNameProps {
name: string
starred?: boolean
}
export function StorageTargetName({ name, starred = false }: StorageTargetNameProps) {
return (
<span
aria-label={starred ? `${name},已收藏` : undefined}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
{starred ? <IconStarFill /> : null}
<span>{name}</span>
</span>
)
}

View File

@@ -4,8 +4,9 @@ import { getStorageTargetFieldConfigs, getStorageTargetTypeLabel } from './field
describe('storage target field config', () => {
it('returns local disk field config', () => {
const fields = getStorageTargetFieldConfigs('local_disk')
expect(fields).toHaveLength(1)
expect(fields).toHaveLength(2)
expect(fields[0]?.key).toBe('basePath')
expect(fields[1]).toMatchObject({ key: 'masterRelay', type: 'switch' })
})
it('returns readable type labels', () => {

View File

@@ -7,6 +7,7 @@ import type { StorageTargetFieldConfig, StorageTargetType } from '../../types/st
const BUILTIN_FIELD_CONFIG: Record<string, StorageTargetFieldConfig[]> = {
local_disk: [
{ key: 'basePath', label: '基础目录', type: 'input', required: true, placeholder: '/data/backups', description: 'BackupX 将在该目录下创建和管理备份文件。' },
{ key: 'masterRelay', label: '远程备份经 Master 中转', type: 'switch', description: '开启后,远程 Agent 会把产物流式传给 Master 并写入上述目录;关闭则沿用 Agent 本机目录。' },
],
s3: [
{ key: 'endpoint', label: 'Endpoint', type: 'input', required: true, placeholder: 'https://s3.amazonaws.com' },

21
web/src/i18n.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import i18n, { normalizeLanguage, setApplicationLanguage } from './i18n'
describe('application language', () => {
afterEach(async () => {
await setApplicationLanguage('zh-CN')
})
it('normalizes supported English variants', () => {
expect(normalizeLanguage('en')).toBe('en-US')
expect(normalizeLanguage('en-GB')).toBe('en-US')
expect(normalizeLanguage('zh-CN')).toBe('zh-CN')
})
it('persists the language selected before login', async () => {
await setApplicationLanguage('en-US')
expect(localStorage.getItem('backupx-language')).toBe('en-US')
expect(document.documentElement.lang).toBe('en-US')
expect(i18n.t('auth.setupTitle')).toBe('System setup')
})
})

View File

@@ -3,7 +3,22 @@ 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'
export type SupportedLanguage = 'zh-CN' | 'en-US'
export const languageOptions: Array<{ label: string; value: SupportedLanguage }> = [
{ label: '中文', value: 'zh-CN' },
{ label: 'English', value: 'en-US' },
]
export function normalizeLanguage(value?: string | null): SupportedLanguage {
return value?.toLowerCase().startsWith('en') ? 'en-US' : 'zh-CN'
}
const savedLanguage = normalizeLanguage(typeof window === 'undefined' ? null : window.localStorage.getItem('backupx-language'))
if (typeof document !== 'undefined') {
document.documentElement.lang = savedLanguage
}
i18n.use(initReactI18next).init({
resources: {
@@ -17,4 +32,14 @@ i18n.use(initReactI18next).init({
},
})
export async function setApplicationLanguage(language: SupportedLanguage) {
if (typeof window !== 'undefined') {
window.localStorage.setItem('backupx-language', language)
}
if (typeof document !== 'undefined') {
document.documentElement.lang = language
}
await i18n.changeLanguage(language)
}
export default i18n

View File

@@ -21,7 +21,7 @@ import {
IconDesktop,
IconList,
IconFilePdf,
} from '@arco-design/web-react/icon'
} from '../components/icons'
import { useState } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import {

View File

@@ -9,7 +9,7 @@ import {
IconDashboard,
IconInfoCircle,
IconPoweroff,
} from '@arco-design/web-react/icon';
} from '../components/icons';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth';

View File

@@ -40,7 +40,62 @@
"oldPassword": "Old Password",
"newPassword": "New Password",
"loginTitle": "Sign in to BackupX",
"loginSubtitle": "Linux Server Backup Manager"
"loginSubtitle": "Linux Server Backup Manager",
"language": "Language",
"bannerTitle": "Protect your data",
"bannerSubtitle": "Secure and reliable server backup management",
"setupStatusTitle": "Connect to BackupX",
"checkingStatus": "Checking system initialization status...",
"statusErrorTitle": "Unable to check initialization status",
"statusErrorDescription": "The web console could not reach the BackupX setup API. Confirm that the service is running, then retry.",
"retry": "Retry",
"setupTitle": "System setup",
"welcomeTitle": "Welcome back",
"setupSubtitle": "Create the first administrator account.",
"welcomeSubtitle": "Enter an administrator account to open the console.",
"displayName": "Display name",
"displayNamePlaceholder": "Administrator display name",
"usernamePlaceholder": "Administrator username",
"passwordPlaceholder": "Password",
"setupPasswordPlaceholder": "At least 8 characters",
"setupSubmit": "Create administrator and sign in",
"setupSuccess": "Setup complete. Opening the console...",
"loginSuccess": "Signed in",
"credentialsRequired": "Enter your username and password first",
"mfaCode": "Verification or recovery code",
"mfaCodePlaceholder": "TOTP, recovery, email, or SMS code",
"sendEmailCode": "Send email code",
"sendSmsCode": "Send SMS code",
"emailCodeSent": "Email verification code sent",
"smsCodeSent": "SMS verification code sent",
"usePasskey": "Use passkey",
"trustDevice": "Trust this device for 30 days",
"verifyAndLogin": "Verify and sign in",
"requestFailed": "The request failed. Please try again.",
"validation": {
"displayNameRequired": "Enter a display name",
"usernameRequired": "Enter a username",
"usernameLength": "Username must contain at least 3 characters",
"passwordRequired": "Enter a password",
"passwordLength": "Password must contain at least 8 characters",
"mfaRequired": "Enter a verification or recovery code",
"mfaLength": "Code must contain 6 to 32 characters"
},
"errors": {
"AUTH_INVALID_CREDENTIALS": "Invalid username or password",
"AUTH_WRONG_PASSWORD": "Invalid username or password",
"AUTH_USER_DISABLED": "This account is disabled",
"AUTH_RATE_LIMITED": "Too many attempts. Please try again later.",
"AUTH_2FA_REQUIRED": "Complete two-factor authentication to continue",
"AUTH_2FA_INVALID": "The verification or recovery code is invalid",
"AUTH_SETUP_DISABLED": "BackupX is already initialized. Sign in instead.",
"AUTH_USERNAME_EXISTS": "This username already exists",
"AUTH_EMAIL_OTP_DISABLED": "Email verification is not enabled",
"AUTH_SMS_OTP_DISABLED": "SMS verification is not enabled",
"AUTH_EMAIL_REQUIRED": "No email address is configured for this account",
"AUTH_PHONE_REQUIRED": "No phone number is configured for this account",
"AUTH_WEBAUTHN_NOT_ENABLED": "No passkey is configured for this account"
}
},
"dashboard": {
"title": "Dashboard",

View File

@@ -40,7 +40,62 @@
"oldPassword": "旧密码",
"newPassword": "新密码",
"loginTitle": "登录 BackupX",
"loginSubtitle": "Linux 服务器备份管理系统"
"loginSubtitle": "Linux 服务器备份管理系统",
"language": "语言",
"bannerTitle": "守护您的数据资产",
"bannerSubtitle": "安全、可靠的服务器备份管理平台",
"setupStatusTitle": "连接 BackupX",
"checkingStatus": "正在检查系统初始化状态...",
"statusErrorTitle": "无法检查初始化状态",
"statusErrorDescription": "Web 控制台无法访问 BackupX 初始化接口。请确认服务已启动,然后重试。",
"retry": "重试",
"setupTitle": "系统初始化",
"welcomeTitle": "欢迎回来",
"setupSubtitle": "请创建首个管理员账户以完成初始化。",
"welcomeSubtitle": "请输入管理员账户信息登录控制台。",
"displayName": "显示名称",
"displayNamePlaceholder": "请输入管理员显示名称",
"usernamePlaceholder": "请输入管理员用户名",
"passwordPlaceholder": "请输入密码",
"setupPasswordPlaceholder": "请输入至少 8 位密码",
"setupSubmit": "创建管理员并登录",
"setupSuccess": "初始化完成,正在进入控制台...",
"loginSuccess": "登录成功",
"credentialsRequired": "请先输入用户名和密码",
"mfaCode": "验证码或恢复码",
"mfaCodePlaceholder": "请输入 TOTP、恢复码、邮件或短信验证码",
"sendEmailCode": "发送邮件验证码",
"sendSmsCode": "发送短信验证码",
"emailCodeSent": "邮件验证码已发送",
"smsCodeSent": "短信验证码已发送",
"usePasskey": "使用通行密钥",
"trustDevice": "信任此设备 30 天",
"verifyAndLogin": "验证并登录",
"requestFailed": "请求失败,请稍后重试",
"validation": {
"displayNameRequired": "请输入显示名称",
"usernameRequired": "请输入用户名",
"usernameLength": "用户名至少需要 3 个字符",
"passwordRequired": "请输入密码",
"passwordLength": "密码至少需要 8 个字符",
"mfaRequired": "请输入验证码或恢复码",
"mfaLength": "验证码或恢复码需为 6 至 32 个字符"
},
"errors": {
"AUTH_INVALID_CREDENTIALS": "用户名或密码错误",
"AUTH_WRONG_PASSWORD": "用户名或密码错误",
"AUTH_USER_DISABLED": "该账户已被停用",
"AUTH_RATE_LIMITED": "尝试次数过多,请稍后再试",
"AUTH_2FA_REQUIRED": "请完成双因素验证后继续",
"AUTH_2FA_INVALID": "验证码或恢复码无效",
"AUTH_SETUP_DISABLED": "系统已完成初始化,请直接登录",
"AUTH_USERNAME_EXISTS": "该用户名已存在",
"AUTH_EMAIL_OTP_DISABLED": "邮件验证码未启用",
"AUTH_SMS_OTP_DISABLED": "短信验证码未启用",
"AUTH_EMAIL_REQUIRED": "该账户未配置邮箱",
"AUTH_PHONE_REQUIRED": "该账户未配置手机号",
"AUTH_WEBAUTHN_NOT_ENABLED": "该账户未配置通行密钥"
}
},
"dashboard": {
"title": "仪表盘",

View File

@@ -114,6 +114,7 @@ export function BackupRecordsPage() {
<Typography.Text>{record.fileName || '-'}</Typography.Text>
{record.locked && <Tag color="orange" size="small" bordered></Tag>}
{record.backupKind === 'differential' && <Tag color="purple" size="small" bordered></Tag>}
{record.backupKind === 'repository' && <Tag color="blue" size="small" bordered>CDC</Tag>}
</Space>
<Typography.Text type="secondary">{formatBytes(record.fileSize)}</Typography.Text>
{record.checksum && (

View File

@@ -1,5 +1,5 @@
import { Alert, Avatar, Card, Empty, Grid, PageHeader, Space, Table, Tag, Typography } from '@arco-design/web-react'
import { IconCheckCircle, IconDesktop, IconHistory, IconSafe, IconSave, IconStorage } from '@arco-design/web-react/icon'
import { IconCheckCircle, IconDesktop, IconHistory, IconSafe, IconSave, IconStorage } from '../../components/icons'
import ReactEChartsCore from 'echarts-for-react/lib/core'
import * as echarts from 'echarts/core'
import { BarChart, LineChart, PieChart } from 'echarts/charts'

View File

@@ -0,0 +1,70 @@
import { act, cleanup, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { setApplicationLanguage } from '../../i18n'
import { LoginPage } from './LoginPage'
const mocks = vi.hoisted(() => ({
fetchSetupStatus: vi.fn(),
login: vi.fn(),
setup: vi.fn(),
}))
vi.mock('../../services/auth', () => ({
beginWebAuthnLogin: vi.fn(),
fetchSetupStatus: mocks.fetchSetupStatus,
sendLoginOtp: vi.fn(),
}))
vi.mock('../../stores/auth', () => ({
useAuthStore: (selector: (state: unknown) => unknown) => selector({
status: 'anonymous',
login: mocks.login,
setup: mocks.setup,
}),
}))
vi.mock('../../utils/webauthn', () => ({
getWebAuthnAssertion: vi.fn(),
}))
describe('LoginPage initialization', () => {
beforeEach(async () => {
mocks.fetchSetupStatus.mockReset()
mocks.login.mockReset()
mocks.setup.mockReset()
await act(() => setApplicationLanguage('en-US'))
})
afterEach(async () => {
cleanup()
await act(() => setApplicationLanguage('zh-CN'))
})
it('shows the first-administrator form in English', async () => {
mocks.fetchSetupStatus.mockResolvedValue({ initialized: false })
render(
<MemoryRouter>
<LoginPage />
</MemoryRouter>,
)
expect(await screen.findByText('System setup')).toBeInTheDocument()
expect(screen.getByText('Create the first administrator account.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Create administrator and sign in' })).toBeInTheDocument()
})
it('does not mistake an unreachable fresh install for an initialized system', async () => {
mocks.fetchSetupStatus.mockRejectedValue(new Error('connection refused'))
render(
<MemoryRouter>
<LoginPage />
</MemoryRouter>,
)
expect(await screen.findByText('Unable to check initialization status')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.queryByText('Welcome back')).not.toBeInTheDocument()
})
})

View File

@@ -1,8 +1,10 @@
import { Button, Checkbox, Form, Input, Space, Typography, Message } from '@arco-design/web-react'
import { IconCloud, IconLock, IconSafe, IconUser } from '@arco-design/web-react/icon'
import { useEffect, useState } from 'react'
import { BackupServerIllustration, IconCloud, IconLock, IconSafe, IconUser } from '../../components/icons'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { LanguageSwitcher } from '../../components/common/LanguageSwitcher'
import { beginWebAuthnLogin, fetchSetupStatus, sendLoginOtp } from '../../services/auth'
import { useAuthStore } from '../../stores/auth'
import { getWebAuthnAssertion } from '../../utils/webauthn'
@@ -20,17 +22,8 @@ interface LoginFormValues {
rememberDevice?: boolean
}
function resolveErrorMessage(error: unknown) {
if (axios.isAxiosError(error)) {
return error.response?.data?.message ?? '请求失败,请稍后重试'
}
if (error instanceof Error) {
return error.message
}
return '请求失败,请稍后重试'
}
export function LoginPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
const authStatus = useAuthStore((state) => state.status)
const doLogin = useAuthStore((state) => state.login)
@@ -40,6 +33,25 @@ export function LoginPage() {
const [loading, setLoading] = useState(false)
const [mfaActionLoading, setMfaActionLoading] = useState('')
const [twoFactorRequired, setTwoFactorRequired] = useState(false)
const [setupStatusFailed, setSetupStatusFailed] = useState(false)
const setupStatusRequest = useRef(0)
function resolveErrorMessage(error: unknown) {
if (axios.isAxiosError(error)) {
const code = error.response?.data?.code
const translationKey = code ? `auth.errors.${code}` : ''
if (translationKey && i18n.exists(translationKey)) {
return t(translationKey)
}
if (i18n.resolvedLanguage === 'zh-CN' && error.response?.data?.message) {
return error.response.data.message
}
}
if (error instanceof Error && i18n.resolvedLanguage === 'zh-CN') {
return error.message
}
return t('auth.requestFailed')
}
function resetTwoFactorPrompt() {
if (!twoFactorRequired) {
@@ -56,30 +68,36 @@ export function LoginPage() {
}
}, [authStatus, navigate])
useEffect(() => {
let mounted = true
void (async () => {
try {
const result = await fetchSetupStatus()
if (mounted) {
setInitialized(result.initialized)
}
} catch {
if (mounted) {
setInitialized(true)
}
const loadSetupStatus = useCallback(async () => {
const requestID = ++setupStatusRequest.current
setInitialized(null)
setSetupStatusFailed(false)
try {
const result = await fetchSetupStatus()
if (requestID === setupStatusRequest.current) {
setInitialized(result.initialized)
}
} catch {
// Do not guess that an unreachable fresh install is initialized. That
// would hide the first-administrator form behind an impossible login.
if (requestID === setupStatusRequest.current) {
setSetupStatusFailed(true)
}
})()
return () => {
mounted = false
}
}, [])
useEffect(() => {
void loadSetupStatus()
return () => {
setupStatusRequest.current++
}
}, [loadSetupStatus])
const handleSetup = async (values: SetupFormValues) => {
setLoading(true)
try {
await doSetup(values)
Message.success('初始化完成,正在进入控制台')
Message.success(t('auth.setupSuccess'))
navigate('/dashboard', { replace: true })
} catch (error) {
Message.error(resolveErrorMessage(error))
@@ -96,7 +114,7 @@ export function LoginPage() {
trustedDeviceName: values.rememberDevice ? navigator.userAgent.slice(0, 120) : undefined,
})
setTwoFactorRequired(false)
Message.success('登录成功')
Message.success(t('auth.loginSuccess'))
navigate('/dashboard', { replace: true })
} catch (error) {
if (axios.isAxiosError(error)) {
@@ -116,7 +134,7 @@ export function LoginPage() {
function readLoginCredentials(): (LoginFormValues & { username: string; password: string }) | null {
const values = loginForm.getFieldsValue()
if (!values.username?.trim() || !values.password?.trim()) {
Message.error('请先输入用户名和密码')
Message.error(t('auth.credentialsRequired'))
return null
}
return {
@@ -132,7 +150,7 @@ export function LoginPage() {
setMfaActionLoading(channel)
try {
await sendLoginOtp({ username: values.username, password: values.password, channel })
Message.success(channel === 'email' ? '邮件验证码已发送' : '短信验证码已发送')
Message.success(channel === 'email' ? t('auth.emailCodeSent') : t('auth.smsCodeSent'))
} catch (error) {
Message.error(resolveErrorMessage(error))
} finally {
@@ -156,7 +174,7 @@ export function LoginPage() {
trustedDeviceName: navigator.userAgent.slice(0, 120),
})
setTwoFactorRequired(false)
Message.success('登录成功')
Message.success(t('auth.loginSuccess'))
navigate('/dashboard', { replace: true })
} catch (error) {
Message.error(resolveErrorMessage(error))
@@ -165,128 +183,124 @@ export function LoginPage() {
}
}
const pageTitle = initialized === null
? t('auth.setupStatusTitle')
: initialized
? t('auth.welcomeTitle')
: t('auth.setupTitle')
const pageSubtitle = initialized === null
? setupStatusFailed ? t('auth.statusErrorDescription') : t('auth.checkingStatus')
: initialized
? t('auth.welcomeSubtitle')
: t('auth.setupSubtitle')
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"/>
<BackupServerIllustration style={{ marginBottom: 16 }} />
{/* 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 heading={2} style={{ color: 'white', marginTop: 0, marginBottom: 12 }}>
{t('auth.bannerTitle')}
</Typography.Title>
<Typography.Text style={{ color: 'rgba(255,255,255,0.75)', fontSize: 16 }}>
{t('auth.bannerSubtitle')}
</Typography.Text>
</div>
</div>
<div className="login-form-wrapper">
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<LanguageSwitcher />
</div>
<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 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 36, height: 36, borderRadius: 4, background: 'var(--color-primary-6)', marginRight: 12 }}>
<IconCloud style={{ fontSize: 20, color: 'white' }} />
</div>
<Typography.Title heading={4} style={{ margin: 0, fontWeight: 700 }}>
<Typography.Title heading={4} style={{ margin: 0 }}>
BackupX
</Typography.Title>
</div>
<Typography.Title heading={3} style={{ marginTop: 0, marginBottom: 8, fontWeight: 600 }}>
{initialized === false ? '系统初始化' : '欢迎回来'}
<Typography.Title heading={3} style={{ marginTop: 0, marginBottom: 8 }}>
{pageTitle}
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 14 }}>
{initialized === false ? '请设定首个管理员账户以启动系统。' : '请输入管理员账户信息登录控制台。'}
{pageSubtitle}
</Typography.Paragraph>
</div>
{initialized === false ? (
{initialized === null ? (
setupStatusFailed ? (
<div>
<Typography.Text>{t('auth.statusErrorTitle')}</Typography.Text>
<div style={{ marginTop: 12 }}>
<Button type="primary" loading={loading} onClick={() => void loadSetupStatus()}>
{t('auth.retry')}
</Button>
</div>
</div>
) : (
<Typography.Text type="secondary">{t('auth.checkingStatus')}</Typography.Text>
)
) : 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 field="displayName" label={t('auth.displayName')} rules={[{ required: true, minLength: 1, message: t('auth.validation.displayNameRequired') }]}>
<Input autoComplete="name" placeholder={t('auth.displayNamePlaceholder')} prefix={<IconUser />} size="large" />
</Form.Item>
<Form.Item field="username" label="用户名" rules={[{ required: true, minLength: 3 }]}>
<Input placeholder="请输入管理员用户名" prefix={<IconUser />} size="large" />
<Form.Item field="username" label={t('auth.username')} rules={[
{ required: true, message: t('auth.validation.usernameRequired') },
{ minLength: 3, message: t('auth.validation.usernameLength') },
]}>
<Input autoComplete="username" placeholder={t('auth.usernamePlaceholder')} 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 field="password" label={t('auth.password')} rules={[
{ required: true, message: t('auth.validation.passwordRequired') },
{ minLength: 8, message: t('auth.validation.passwordLength') },
]}>
<Input.Password autoComplete="new-password" placeholder={t('auth.setupPasswordPlaceholder')} prefix={<IconLock />} size="large" />
</Form.Item>
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 8, height: 44, marginTop: 8 }}>
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 4, height: 44, marginTop: 8 }}>
{t('auth.setupSubmit')}
</Button>
</Form>
) : (
<Form<LoginFormValues> form={loginForm} layout="vertical" onSubmit={handleLogin}>
<Form.Item field="username" label="用户名" rules={[{ required: true, minLength: 3 }]}>
<Input placeholder="请输入用户名" prefix={<IconUser />} size="large" onChange={resetTwoFactorPrompt} />
<Form.Item field="username" label={t('auth.username')} rules={[
{ required: true, message: t('auth.validation.usernameRequired') },
{ minLength: 3, message: t('auth.validation.usernameLength') },
]}>
<Input autoComplete="username" placeholder={t('auth.usernamePlaceholder')} prefix={<IconUser />} size="large" onChange={resetTwoFactorPrompt} />
</Form.Item>
<Form.Item field="password" label="密码" rules={[{ required: true, minLength: 8 }]}>
<Input.Password placeholder="请输入密码" prefix={<IconLock />} size="large" onChange={resetTwoFactorPrompt} />
<Form.Item field="password" label={t('auth.password')} rules={[
{ required: true, message: t('auth.validation.passwordRequired') },
{ minLength: 8, message: t('auth.validation.passwordLength') },
]}>
<Input.Password autoComplete="current-password" placeholder={t('auth.passwordPlaceholder')} prefix={<IconLock />} size="large" onChange={resetTwoFactorPrompt} />
</Form.Item>
{twoFactorRequired && (
<>
<Form.Item field="twoFactorCode" label="验证码或恢复码" rules={[{ required: true, minLength: 6, maxLength: 32 }]}>
<Input placeholder="请输入 TOTP、恢复码、邮件或短信验证码" prefix={<IconSafe />} size="large" maxLength={32} />
<Form.Item field="twoFactorCode" label={t('auth.mfaCode')} rules={[
{ required: true, message: t('auth.validation.mfaRequired') },
{ minLength: 6, maxLength: 32, message: t('auth.validation.mfaLength') },
]}>
<Input autoComplete="one-time-code" placeholder={t('auth.mfaCodePlaceholder')} prefix={<IconSafe />} size="large" maxLength={32} />
</Form.Item>
<Space wrap style={{ marginTop: -8, marginBottom: 8 }}>
<Button loading={mfaActionLoading === 'email'} onClick={() => void handleSendOTP('email')}></Button>
<Button loading={mfaActionLoading === 'sms'} onClick={() => void handleSendOTP('sms')}></Button>
<Button loading={mfaActionLoading === 'webauthn'} onClick={() => void handleWebAuthnLogin()}>使</Button>
<Button loading={mfaActionLoading === 'email'} onClick={() => void handleSendOTP('email')}>{t('auth.sendEmailCode')}</Button>
<Button loading={mfaActionLoading === 'sms'} onClick={() => void handleSendOTP('sms')}>{t('auth.sendSmsCode')}</Button>
<Button loading={mfaActionLoading === 'webauthn'} onClick={() => void handleWebAuthnLogin()}>{t('auth.usePasskey')}</Button>
</Space>
<Form.Item field="rememberDevice" triggerPropName="checked">
<Checkbox> 30 </Checkbox>
<Checkbox>{t('auth.trustDevice')}</Checkbox>
</Form.Item>
</>
)}
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 8, height: 44, marginTop: 16 }}>
{twoFactorRequired ? '验证并登录' : '登录'}
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 4, height: 44, marginTop: 16 }}>
{twoFactorRequired ? t('auth.verifyAndLogin') : t('auth.login')}
</Button>
</Form>
)}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'
import { Table, Button, Space, Message, Typography, Tag } from '@arco-design/web-react'
import { IconCopy, IconDownload, IconRefresh } from '@arco-design/web-react/icon'
import { IconCopy, IconDownload, IconRefresh } from '../../components/icons'
const { Text } = Typography

View File

@@ -5,7 +5,7 @@ import {
} from '@arco-design/web-react'
import {
IconPlus, IconDelete, IconDesktop, IconCloudDownload, IconEdit, IconMore,
} from '@arco-design/web-react/icon'
} from '../../components/icons'
import type { NodeSummary } from '../../types/nodes'
import { listNodes, deleteNode, updateNode, rotateNodeToken } from '../../services/nodes'
import { fetchSystemInfo } from '../../services/system'

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'
import { Typography, Button, Space, Collapse, Spin, Message, Tag } from '@arco-design/web-react'
import { IconCopy, IconRefresh } from '@arco-design/web-react/icon'
import { IconCopy, IconRefresh } from '../../../components/icons'
import { fetchScriptPreview } from '../../../services/nodes'
import type { InstallTokenResult, InstallMode } from '../../../types/nodes'
import { buildAgentDownloadCommand, buildAgentInstallCommand, buildEmbeddedAgentInstallCommand } from '../installCommands'

View File

@@ -1,5 +1,5 @@
import { Button, Card, Grid, Message, Select, Space, Statistic, Table, Tag, Typography } from '@arco-design/web-react'
import { IconDownload, IconRefresh } from '@arco-design/web-react/icon'
import { IconDownload, IconRefresh } from '../../components/icons'
import { useCallback, useEffect, useState } from 'react'
import { downloadComplianceCSV, fetchComplianceReport } from '../../services/reports'
import type { ComplianceReport, ComplianceRisk, ComplianceTaskRow } from '../../types/reports'

View File

@@ -18,6 +18,7 @@ import { formatBytes } from '../../utils/format'
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'
import { StorageTargetName } from '../../components/storage-targets/StorageTargetName'
function resolveErrorMessage(error: unknown) {
if (axios.isAxiosError(error)) {
@@ -224,7 +225,7 @@ export function StorageTargetsPage() {
<Space size="large" align="start" style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div>
<Typography.Title heading={6} style={{ marginBottom: 4 }}>
{target.starred ? '★ ' : ''}{target.name}
<StorageTargetName name={target.name} starred={target.starred} />
</Typography.Title>
<Space>
{getStorageTargetTypeLabel(target.type) && <Tag color="arcoblue" bordered>{getStorageTargetTypeLabel(target.type)}</Tag>}

View File

@@ -42,48 +42,25 @@ body {
.login-bg {
position: fixed;
inset: 0;
background: linear-gradient(135deg, #111a2c 0%, #1f2d47 100%);
background: #111a2c;
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;
border-radius: 4px;
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%);
background: var(--color-primary-6, #165dff);
position: relative;
display: flex;
align-items: center;

View File

@@ -24,3 +24,17 @@ Object.defineProperty(window, 'localStorage', {
value: storage,
configurable: true,
})
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}),
})

View File

@@ -21,12 +21,13 @@ export interface BackupRecordSummary {
fileSize: number
checksum: string
storagePath: string
storageTransferMode?: 'direct' | 'master_relay'
durationSeconds: number
errorMessage: string
startedAt: string
completedAt?: string
locked: boolean
backupKind: 'full' | 'differential'
backupKind: 'full' | 'differential' | 'repository'
}
export interface BackupRecordContentEntry {
@@ -49,6 +50,7 @@ export interface StorageUploadResultItem {
status: 'success' | 'failed'
storagePath?: string
fileSize?: number
transferMode?: 'direct' | 'master_relay'
error?: string
}

View File

@@ -1,7 +1,7 @@
export type BackupTaskType = 'file' | 'mysql' | 'sqlite' | 'postgresql' | 'saphana' | 'mongodb'
export type BackupTaskStatus = 'idle' | 'running' | 'success' | 'failed'
export type BackupCompression = 'gzip' | 'zstd' | 'none'
export type BackupMode = 'full' | 'differential'
export type BackupMode = 'full' | 'differential' | 'repository'
export interface BackupTaskSummary {
id: number