mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-22 00:29:34 +08:00
feat: add community enhancements — password reset, audit logs, multi-source backup
Three community-requested features: 1. CLI password reset: `backupx reset-password --username admin --password xxx` Docker users can run via `docker exec`. No full app init needed. 2. Audit logging: async fire-and-forget audit trail for all key operations (login, CRUD on tasks/targets/records, settings changes). New UI page at /audit with category filter and pagination. 3. Multi-source path backup: file backup tasks now support multiple source directories packed into a single tar archive. Backward compatible with existing single sourcePath field.
This commit is contained in:
120
web/src/components/common/DatabasePicker.tsx
Normal file
120
web/src/components/common/DatabasePicker.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Button, Checkbox, Input, Message, Space, Spin, Typography } from '@arco-design/web-react'
|
||||
import { useState } from 'react'
|
||||
import { discoverDatabases } from '../../services/database'
|
||||
|
||||
interface DatabasePickerProps {
|
||||
dbType: 'mysql' | 'postgresql'
|
||||
dbHost: string
|
||||
dbPort: number
|
||||
dbUser: string
|
||||
dbPassword: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function DatabasePicker({ dbType, dbHost, dbPort, dbUser, dbPassword, value, onChange }: DatabasePickerProps) {
|
||||
const [databases, setDatabases] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [discovered, setDiscovered] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const selectedDbs = value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const canDiscover = dbHost.trim() && dbPort > 0 && dbUser.trim() && dbPassword.trim()
|
||||
|
||||
async function handleDiscover() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await discoverDatabases({
|
||||
type: dbType,
|
||||
host: dbHost.trim(),
|
||||
port: dbPort,
|
||||
user: dbUser.trim(),
|
||||
password: dbPassword.trim(),
|
||||
})
|
||||
setDatabases(result)
|
||||
setDiscovered(true)
|
||||
if (result.length === 0) {
|
||||
setError('未发现用户数据库')
|
||||
}
|
||||
} catch (discoverError: any) {
|
||||
const msg = discoverError?.response?.data?.message ?? discoverError?.message ?? '发现数据库失败'
|
||||
setError(msg)
|
||||
Message.error(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(db: string, checked: boolean) {
|
||||
let next: string[]
|
||||
if (checked) {
|
||||
next = [...selectedDbs, db]
|
||||
} else {
|
||||
next = selectedDbs.filter((d) => d !== db)
|
||||
}
|
||||
onChange(next.join(','))
|
||||
}
|
||||
|
||||
function handleSelectAll() {
|
||||
onChange(databases.join(','))
|
||||
}
|
||||
|
||||
function handleDeselectAll() {
|
||||
onChange('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%' }}>
|
||||
<Input
|
||||
style={{ flex: 1 }}
|
||||
value={value}
|
||||
placeholder="数据库名称(多个以逗号分隔)"
|
||||
onChange={onChange}
|
||||
/>
|
||||
<Button
|
||||
type="outline"
|
||||
size="small"
|
||||
loading={loading}
|
||||
disabled={!canDiscover}
|
||||
onClick={handleDiscover}
|
||||
>
|
||||
发现数据库
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{error && <Typography.Text type="error">{error}</Typography.Text>}
|
||||
|
||||
{loading && <Spin size={16} />}
|
||||
|
||||
{discovered && databases.length > 0 && (
|
||||
<div style={{ border: '1px solid var(--color-border-2)', borderRadius: 4, padding: '8px 12px', maxHeight: 200, overflow: 'auto' }}>
|
||||
<Space size="mini" style={{ marginBottom: 8 }}>
|
||||
<Button type="text" size="mini" onClick={handleSelectAll}>
|
||||
全选
|
||||
</Button>
|
||||
<Button type="text" size="mini" onClick={handleDeselectAll}>
|
||||
清空
|
||||
</Button>
|
||||
</Space>
|
||||
<Space direction="vertical" size={4}>
|
||||
{databases.map((db) => (
|
||||
<Checkbox
|
||||
key={db}
|
||||
checked={selectedDbs.includes(db)}
|
||||
onChange={(checked) => handleToggle(db, checked)}
|
||||
>
|
||||
{db}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
141
web/src/components/common/DirectoryPicker.tsx
Normal file
141
web/src/components/common/DirectoryPicker.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Button, Input, Modal, Space, Spin, Tree, Typography } from '@arco-design/web-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { listNodeDirectory } from '../../services/nodes'
|
||||
import type { DirEntry } from '../../types/nodes'
|
||||
|
||||
interface DirectoryPickerProps {
|
||||
value: string
|
||||
onChange: (path: string) => void
|
||||
placeholder?: string
|
||||
mode?: 'directory' | 'file'
|
||||
nodeId?: number
|
||||
}
|
||||
|
||||
interface TreeNodeData {
|
||||
key: string
|
||||
title: string
|
||||
isLeaf: boolean
|
||||
children?: TreeNodeData[]
|
||||
}
|
||||
|
||||
function entriesToTreeNodes(entries: DirEntry[], mode: 'directory' | 'file'): TreeNodeData[] {
|
||||
return entries
|
||||
.filter((entry) => mode === 'file' || entry.isDir)
|
||||
.map((entry) => ({
|
||||
key: entry.path,
|
||||
title: entry.name,
|
||||
isLeaf: !entry.isDir,
|
||||
children: entry.isDir ? [] : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
export function DirectoryPicker({ value, onChange, placeholder, mode = 'directory', nodeId }: DirectoryPickerProps) {
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [treeData, setTreeData] = useState<TreeNodeData[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedPath, setSelectedPath] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const loadDirectory = useCallback(
|
||||
async (path: string) => {
|
||||
if (nodeId === undefined) return []
|
||||
try {
|
||||
const entries = await listNodeDirectory(nodeId, path)
|
||||
return entriesToTreeNodes(entries, mode)
|
||||
} catch {
|
||||
setError('加载目录失败')
|
||||
return []
|
||||
}
|
||||
},
|
||||
[nodeId, mode],
|
||||
)
|
||||
|
||||
async function handleOpen() {
|
||||
setModalVisible(true)
|
||||
setSelectedPath(value || '')
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const rootNodes = await loadDirectory('/')
|
||||
setTreeData(rootNodes)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoadMore(node: TreeNodeData) {
|
||||
const children = await loadDirectory(node.key)
|
||||
setTreeData((prev) => {
|
||||
function updateChildren(nodes: TreeNodeData[]): TreeNodeData[] {
|
||||
return nodes.map((n) => {
|
||||
if (n.key === node.key) {
|
||||
return { ...n, children }
|
||||
}
|
||||
if (n.children) {
|
||||
return { ...n, children: updateChildren(n.children) }
|
||||
}
|
||||
return n
|
||||
})
|
||||
}
|
||||
return updateChildren(prev)
|
||||
})
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (selectedPath) {
|
||||
onChange(selectedPath)
|
||||
}
|
||||
setModalVisible(false)
|
||||
}
|
||||
|
||||
if (nodeId === undefined) {
|
||||
return <Input value={value} placeholder={placeholder} onChange={onChange} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space style={{ width: '100%' }}>
|
||||
<Input style={{ flex: 1 }} value={value} placeholder={placeholder} onChange={onChange} />
|
||||
<Button type="outline" size="small" onClick={handleOpen}>
|
||||
浏览
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title={mode === 'directory' ? '选择目录' : '选择文件'}
|
||||
visible={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onOk={handleConfirm}
|
||||
okText="选择"
|
||||
cancelText="取消"
|
||||
style={{ width: 520 }}
|
||||
okButtonProps={{ disabled: !selectedPath }}
|
||||
>
|
||||
{error && <Typography.Text type="error">{error}</Typography.Text>}
|
||||
{selectedPath && (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
已选择: {selectedPath}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{loading ? (
|
||||
<Spin style={{ display: 'block', textAlign: 'center', padding: 24 }} />
|
||||
) : treeData.length === 0 ? (
|
||||
<Typography.Text type="secondary">目录为空</Typography.Text>
|
||||
) : (
|
||||
<div style={{ maxHeight: 400, overflow: 'auto' }}>
|
||||
<Tree
|
||||
treeData={treeData as any}
|
||||
onSelect={(keys) => {
|
||||
if (keys.length > 0) {
|
||||
setSelectedPath(keys[0] as string)
|
||||
}
|
||||
}}
|
||||
selectedKeys={selectedPath ? [selectedPath] : []}
|
||||
loadMore={(node: any) => handleLoadMore(node.props)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user