mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-07-03 15:31:22 +08:00
feat(restore): 恢复到指定目录(文件类型本机恢复 + 确认弹窗输入) (#86)
* feat(restore): 支持恢复到指定目录(文件类型本机恢复)
恢复此前只能覆盖原始源路径。新增「恢复到指定目录」:把文件备份还原到任意目录,
用于测试恢复、迁移、并排恢复而不覆盖现网数据。
- backup.TaskSpec +RestoreTargetPath;FileRunner.Restore 非空时把归档解压到该目录。
- model.RestoreRecord +TargetPath(持久化/审计)。
- RestoreService.Start 增加 targetPath 参数与校验:仅文件类型、需绝对路径、
远程节点暂不支持(清晰报错);executeLocally 透传到 spec。
- 恢复触发端点接受可选请求体 {targetPath}(无 body 时恢复到原始路径)。
- 测试:恢复到指定目录后文件落在该目录;相对路径被拒。
* feat(restore): 恢复确认弹窗支持指定恢复目录
文件类型 + 本机恢复时,恢复确认弹窗新增「恢复到指定目录」输入(可选、绝对路径、
留空=原位置),并实时反映在「恢复目标」摘要中;经 startRestoreFromBackup 透传 targetPath。
This commit is contained in:
@@ -193,13 +193,13 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmRestore() {
|
||||
async function handleConfirmRestore(targetPath?: string) {
|
||||
if (!recordId) {
|
||||
return
|
||||
}
|
||||
setRestoreLoading(true)
|
||||
try {
|
||||
const restore = await startRestoreFromBackup(recordId)
|
||||
const restore = await startRestoreFromBackup(recordId, undefined, targetPath)
|
||||
Message.success('恢复已启动,正在打开日志')
|
||||
setRestoreModalVisible(false)
|
||||
setRestoreTask(null)
|
||||
@@ -345,7 +345,7 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
||||
setRestoreModalVisible(false)
|
||||
setRestoreTask(null)
|
||||
}}
|
||||
onConfirm={() => void handleConfirmRestore()}
|
||||
onConfirm={(targetPath) => void handleConfirmRestore(targetPath)}
|
||||
/>
|
||||
<BackupRecordContentsModal
|
||||
visible={contentsVisible}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Alert, Descriptions, Modal, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { Alert, Descriptions, Input, Modal, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useState } from 'react'
|
||||
import type { BackupRecordDetail } from '../../types/backup-records'
|
||||
import type { BackupTaskDetail } from '../../types/backup-tasks'
|
||||
|
||||
@@ -8,21 +9,26 @@ interface RestoreConfirmModalProps {
|
||||
backupRecord: BackupRecordDetail | null
|
||||
task: BackupTaskDetail | null
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
onConfirm: (targetPath?: string) => void
|
||||
}
|
||||
|
||||
// RestoreConfirmModal 展示即将恢复的备份摘要与覆盖风险,强制用户二次确认。
|
||||
// 恢复是破坏性操作:会覆盖任务配置的源路径/数据库,不可撤销。
|
||||
// 文件类型 + 本机恢复时,允许指定「恢复到其他目录」以避免覆盖原位置。
|
||||
export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCancel, onConfirm }: RestoreConfirmModalProps) {
|
||||
const [targetPath, setTargetPath] = useState('')
|
||||
|
||||
if (!backupRecord || !task) {
|
||||
return (
|
||||
<Modal visible={visible} title="确认恢复" onCancel={onCancel} onOk={onConfirm} confirmLoading={loading} unmountOnExit>
|
||||
<Modal visible={visible} title="确认恢复" onCancel={onCancel} onOk={() => onConfirm()} confirmLoading={loading} unmountOnExit>
|
||||
<Alert type="info" content="正在加载任务与备份信息..." />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const restoreTarget = renderRestoreTarget(task)
|
||||
const isLocal = !task.nodeId || task.nodeId === 0
|
||||
const allowAltPath = task.type === 'file' && isLocal
|
||||
const nodeLabel = task.nodeId && task.nodeId > 0
|
||||
? (task.nodeName ? `${task.nodeName}(远程节点)` : `节点 #${task.nodeId}`)
|
||||
: '本机 Master'
|
||||
@@ -35,8 +41,9 @@ export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCa
|
||||
cancelText="取消"
|
||||
okButtonProps={{ status: 'danger', loading }}
|
||||
onCancel={onCancel}
|
||||
onOk={onConfirm}
|
||||
onOk={() => onConfirm(targetPath.trim() || undefined)}
|
||||
unmountOnExit
|
||||
afterClose={() => setTargetPath('')}
|
||||
>
|
||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||
<Alert
|
||||
@@ -51,9 +58,23 @@ export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCa
|
||||
{ label: '类型', value: <Tag color="arcoblue" bordered>{task.type.toUpperCase()}</Tag> },
|
||||
{ label: '执行节点', value: nodeLabel },
|
||||
{ label: '源备份', value: backupRecord.fileName || '-' },
|
||||
{ label: '恢复目标', value: restoreTarget },
|
||||
{ label: '恢复目标', value: targetPath.trim() ? <Typography.Text code>{targetPath.trim()}</Typography.Text> : restoreTarget },
|
||||
]}
|
||||
/>
|
||||
{allowAltPath && (
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
恢复到指定目录(可选,绝对路径;留空则恢复到原始位置)
|
||||
</Typography.Text>
|
||||
<Input
|
||||
style={{ marginTop: 4 }}
|
||||
allowClear
|
||||
value={targetPath}
|
||||
placeholder="/path/to/restore-here"
|
||||
onChange={(value) => setTargetPath(value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -39,9 +39,18 @@ export async function getRestoreRecord(id: number) {
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
// startRestoreFromBackup 通过源备份记录启动恢复。selectedPaths 非空时为按需(选择性)恢复。
|
||||
export async function startRestoreFromBackup(backupRecordId: number, selectedPaths?: string[]) {
|
||||
const body = selectedPaths && selectedPaths.length > 0 ? { selectedPaths } : undefined
|
||||
// startRestoreFromBackup 通过源备份记录启动恢复。两个可选项互不影响:
|
||||
// - selectedPaths 非空时为按需(选择性)恢复,仅还原选中的文件/目录;
|
||||
// - targetPath 非空时把文件归档恢复到该绝对目录而非原始路径(仅文件类型本机恢复)。
|
||||
// 返回新建的恢复记录详情。
|
||||
export async function startRestoreFromBackup(backupRecordId: number, selectedPaths?: string[], targetPath?: string) {
|
||||
const body: { selectedPaths?: string[]; targetPath?: string } = {}
|
||||
if (selectedPaths && selectedPaths.length > 0) {
|
||||
body.selectedPaths = selectedPaths
|
||||
}
|
||||
if (targetPath && targetPath.trim()) {
|
||||
body.targetPath = targetPath.trim()
|
||||
}
|
||||
const response = await http.post<ApiEnvelope<RestoreRecordDetail>>(`/backup/records/${backupRecordId}/restore`, body)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user