mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-30 14:01:29 +08:00
feat(backup): 新增按需(选择性)文件恢复 (#91)
在内容浏览基础上支持仅恢复勾选的文件/目录到原位置。FileRunner.Restore 按选中集合过滤提取与删除;RestoreService.StartSelective(Start 委托,零破坏);恢复端点接受可选 selectedPaths;前端内容弹窗支持勾选恢复。
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Alert, Input, Modal, Spin, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { Alert, Button, Input, Modal, Spin, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { getBackupRecordContents } from '../../services/backup-records'
|
||||
import type { BackupRecordContentEntry, BackupRecordContents } from '../../types/backup-records'
|
||||
@@ -9,15 +9,18 @@ interface BackupRecordContentsModalProps {
|
||||
visible: boolean
|
||||
recordId?: number
|
||||
onClose: () => void
|
||||
// onRestoreSelected 提供时启用按需恢复:勾选条目后回调选中的归档路径。
|
||||
onRestoreSelected?: (paths: string[]) => void
|
||||
}
|
||||
|
||||
// BackupRecordContentsModal 浏览某次备份捕获的文件清单(只读)。
|
||||
// 数据来源于全量备份记录的清单,无需下载归档,秒级展示并支持按路径筛选。
|
||||
export function BackupRecordContentsModal({ visible, recordId, onClose }: BackupRecordContentsModalProps) {
|
||||
export function BackupRecordContentsModal({ visible, recordId, onClose, onRestoreSelected }: BackupRecordContentsModalProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [contents, setContents] = useState<BackupRecordContents | null>(null)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !recordId) {
|
||||
@@ -28,6 +31,7 @@ export function BackupRecordContentsModal({ visible, recordId, onClose }: Backup
|
||||
setError('')
|
||||
setKeyword('')
|
||||
setContents(null)
|
||||
setSelectedKeys([])
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await getBackupRecordContents(recordId)
|
||||
@@ -71,11 +75,23 @@ export function BackupRecordContentsModal({ visible, recordId, onClose }: Backup
|
||||
{contents.truncated ? `(清单较大,仅展示前 ${contents.entries.length} 个)` : ''}
|
||||
{contents.basedOnFull ? `;差异备份,清单取自基线全量 #${contents.basedOnFull}` : ''}
|
||||
</Typography.Text>
|
||||
<Input.Search allowClear placeholder="按路径筛选" value={keyword} onChange={setKeyword} style={{ margin: '8px 0' }} />
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', margin: '8px 0' }}>
|
||||
<Input.Search allowClear placeholder="按路径筛选" value={keyword} onChange={setKeyword} style={{ flex: 1 }} />
|
||||
{onRestoreSelected && (
|
||||
<Button type="primary" status="warning" disabled={selectedKeys.length === 0} onClick={() => onRestoreSelected(selectedKeys)}>
|
||||
恢复选中({selectedKeys.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="path"
|
||||
data={filtered}
|
||||
rowSelection={
|
||||
onRestoreSelected
|
||||
? { type: 'checkbox', selectedRowKeys: selectedKeys, onChange: (keys) => setSelectedKeys(keys as string[]) }
|
||||
: undefined
|
||||
}
|
||||
pagination={{ pageSize: 50, sizeCanChange: false }}
|
||||
scroll={{ y: 420 }}
|
||||
columns={[
|
||||
|
||||
@@ -213,6 +213,26 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
||||
}
|
||||
}
|
||||
|
||||
// handleSelectiveRestore 按需恢复:仅还原内容浏览中勾选的文件/目录到原位置。
|
||||
async function handleSelectiveRestore(paths: string[]) {
|
||||
if (!recordId || paths.length === 0) {
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`确定将选中的 ${paths.length} 项恢复到原位置吗?这会覆盖目标位置的现有文件,不可撤销。`)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const restore = await startRestoreFromBackup(recordId, paths)
|
||||
Message.success('按需恢复已启动,正在打开日志')
|
||||
setContentsVisible(false)
|
||||
await onChanged?.()
|
||||
navigate(`/restore/records?restoreId=${restore.id}`)
|
||||
onCancel()
|
||||
} catch (restoreError) {
|
||||
Message.error(resolveErrorMessage(restoreError, '启动按需恢复失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!recordId) {
|
||||
return
|
||||
@@ -327,7 +347,12 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
||||
}}
|
||||
onConfirm={() => void handleConfirmRestore()}
|
||||
/>
|
||||
<BackupRecordContentsModal visible={contentsVisible} recordId={recordId} onClose={() => setContentsVisible(false)} />
|
||||
<BackupRecordContentsModal
|
||||
visible={contentsVisible}
|
||||
recordId={recordId}
|
||||
onClose={() => setContentsVisible(false)}
|
||||
onRestoreSelected={writable && record?.status === 'success' ? (paths) => void handleSelectiveRestore(paths) : undefined}
|
||||
/>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,9 +39,10 @@ export async function getRestoreRecord(id: number) {
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
// startRestoreFromBackup 通过源备份记录启动恢复。返回新建的恢复记录详情。
|
||||
export async function startRestoreFromBackup(backupRecordId: number) {
|
||||
const response = await http.post<ApiEnvelope<RestoreRecordDetail>>(`/backup/records/${backupRecordId}/restore`)
|
||||
// startRestoreFromBackup 通过源备份记录启动恢复。selectedPaths 非空时为按需(选择性)恢复。
|
||||
export async function startRestoreFromBackup(backupRecordId: number, selectedPaths?: string[]) {
|
||||
const body = selectedPaths && selectedPaths.length > 0 ? { selectedPaths } : undefined
|
||||
const response = await http.post<ApiEnvelope<RestoreRecordDetail>>(`/backup/records/${backupRecordId}/restore`, body)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user