mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-05 23:47:33 +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:
@@ -185,6 +185,11 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
|
|||||||
restoreSource = task.SourcePaths[0]
|
restoreSource = task.SourcePaths[0]
|
||||||
}
|
}
|
||||||
targetParent := filepath.Dir(filepath.Clean(strings.TrimSpace(restoreSource)))
|
targetParent := filepath.Dir(filepath.Clean(strings.TrimSpace(restoreSource)))
|
||||||
|
// 恢复到指定位置:非空时归档解压到用户指定目录,而非原始源父目录。
|
||||||
|
if override := strings.TrimSpace(task.RestoreTargetPath); override != "" {
|
||||||
|
targetParent = filepath.Clean(override)
|
||||||
|
writer.WriteLine(fmt.Sprintf("恢复到指定目录:%s", targetParent))
|
||||||
|
}
|
||||||
if err := os.MkdirAll(targetParent, 0o755); err != nil {
|
if err := os.MkdirAll(targetParent, 0o755); err != nil {
|
||||||
return fmt.Errorf("create restore parent: %w", err)
|
return fmt.Errorf("create restore parent: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ type TaskSpec struct {
|
|||||||
BaseManifest Manifest
|
BaseManifest Manifest
|
||||||
// SelectedPaths 非空时仅恢复这些归档相对路径(及其子项),用于按需(选择性)恢复;仅文件类型生效。
|
// SelectedPaths 非空时仅恢复这些归档相对路径(及其子项),用于按需(选择性)恢复;仅文件类型生效。
|
||||||
SelectedPaths []string
|
SelectedPaths []string
|
||||||
|
// RestoreTargetPath 仅用于恢复:非空时,文件类型恢复将归档解压到该目录,
|
||||||
|
// 而非默认的原始源路径父目录。用于「恢复到指定位置」(迁移/测试/并排恢复)。
|
||||||
|
RestoreTargetPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type RunResult struct {
|
type RunResult struct {
|
||||||
|
|||||||
@@ -151,11 +151,14 @@ func (h *BackupRecordHandler) Restore(c *gin.Context) {
|
|||||||
if subject, exists := c.Get(contextUserSubjectKey); exists {
|
if subject, exists := c.Get(contextUserSubjectKey); exists {
|
||||||
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
|
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
|
||||||
}
|
}
|
||||||
|
// 可选请求体:selectedPaths 按需(选择性)恢复;targetPath 恢复到指定目录(仅文件类型本机恢复)。
|
||||||
|
// 无 body 时为整体恢复到原始路径。
|
||||||
var body struct {
|
var body struct {
|
||||||
SelectedPaths []string `json:"selectedPaths"`
|
SelectedPaths []string `json:"selectedPaths"`
|
||||||
|
TargetPath string `json:"targetPath"`
|
||||||
}
|
}
|
||||||
_ = c.ShouldBindJSON(&body) // body 可选:无 body 为整体恢复,含 selectedPaths 为按需恢复
|
_ = c.ShouldBindJSON(&body)
|
||||||
detail, err := h.restoreService.StartSelective(c.Request.Context(), id, body.SelectedPaths, triggeredBy)
|
detail, err := h.restoreService.StartSelective(c.Request.Context(), id, body.SelectedPaths, strings.TrimSpace(body.TargetPath), triggeredBy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,21 +11,23 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type RestoreRecord struct {
|
type RestoreRecord struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
|
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
|
||||||
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
|
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
|
||||||
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
|
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
|
||||||
Task BackupTask `json:"task,omitempty"`
|
Task BackupTask `json:"task,omitempty"`
|
||||||
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
||||||
Status string `gorm:"size:20;index;not null" json:"status"`
|
// TargetPath 恢复到指定目录(仅文件类型本机恢复);空 = 恢复到原始源路径。
|
||||||
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
|
TargetPath string `gorm:"column:target_path;size:500" json:"targetPath"`
|
||||||
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
|
Status string `gorm:"size:20;index;not null" json:"status"`
|
||||||
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
|
||||||
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
|
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
|
||||||
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
|
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
||||||
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
|
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (RestoreRecord) TableName() string {
|
func (RestoreRecord) TableName() string {
|
||||||
|
|||||||
@@ -120,11 +120,13 @@ type RestoreRecordDetail struct {
|
|||||||
// 若任务绑定远程节点:入队 AgentCommand 后立即返回(状态为 running)
|
// 若任务绑定远程节点:入队 AgentCommand 后立即返回(状态为 running)
|
||||||
// 若本地:异步执行并立即返回。
|
// 若本地:异步执行并立即返回。
|
||||||
func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, triggeredBy string) (*RestoreRecordDetail, error) {
|
func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, triggeredBy string) (*RestoreRecordDetail, error) {
|
||||||
return s.StartSelective(ctx, backupRecordID, nil, triggeredBy)
|
return s.StartSelective(ctx, backupRecordID, nil, "", triggeredBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartSelective 启动恢复;selectedPaths 非空时仅恢复选中的文件/目录(按需恢复,仅本机文件备份)。
|
// StartSelective 启动恢复。两个可选项均仅适用于本机文件备份:
|
||||||
func (s *RestoreService) StartSelective(ctx context.Context, backupRecordID uint, selectedPaths []string, triggeredBy string) (*RestoreRecordDetail, error) {
|
// - selectedPaths 非空时仅恢复选中的文件/目录(及其子项),用于按需(选择性)恢复;
|
||||||
|
// - targetPath 非空时把归档恢复到该绝对目录而非原始源路径父目录(迁移/测试/并排恢复)。
|
||||||
|
func (s *RestoreService) StartSelective(ctx context.Context, backupRecordID uint, selectedPaths []string, targetPath string, triggeredBy string) (*RestoreRecordDetail, error) {
|
||||||
record, err := s.records.FindByID(ctx, backupRecordID)
|
record, err := s.records.FindByID(ctx, backupRecordID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录", err)
|
return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录", err)
|
||||||
@@ -153,10 +155,26 @@ func (s *RestoreService) StartSelective(ctx context.Context, backupRecordID uint
|
|||||||
|
|
||||||
startedAt := s.now()
|
startedAt := s.now()
|
||||||
restoreNodeID := s.resolveRestoreNodeID(record, task)
|
restoreNodeID := s.resolveRestoreNodeID(record, task)
|
||||||
|
|
||||||
|
// 恢复到指定目录:仅文件类型 + 本机执行支持;需为绝对路径。
|
||||||
|
targetPath = strings.TrimSpace(targetPath)
|
||||||
|
if targetPath != "" {
|
||||||
|
if task.Type != model.BackupTaskTypeFile {
|
||||||
|
return nil, apperror.BadRequest("RESTORE_TARGET_UNSUPPORTED", "仅文件类型备份支持恢复到指定目录", nil)
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(targetPath) {
|
||||||
|
return nil, apperror.BadRequest("RESTORE_TARGET_INVALID", "恢复目录必须是绝对路径", nil)
|
||||||
|
}
|
||||||
|
if s.isRemoteNode(ctx, restoreNodeID) {
|
||||||
|
return nil, apperror.BadRequest("RESTORE_TARGET_REMOTE_UNSUPPORTED", "远程节点恢复暂不支持指定目录,请在该节点本地操作", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
restore := &model.RestoreRecord{
|
restore := &model.RestoreRecord{
|
||||||
BackupRecordID: backupRecordID,
|
BackupRecordID: backupRecordID,
|
||||||
TaskID: record.TaskID,
|
TaskID: record.TaskID,
|
||||||
NodeID: restoreNodeID,
|
NodeID: restoreNodeID,
|
||||||
|
TargetPath: targetPath,
|
||||||
Status: model.RestoreRecordStatusRunning,
|
Status: model.RestoreRecordStatusRunning,
|
||||||
StartedAt: startedAt,
|
StartedAt: startedAt,
|
||||||
TriggeredBy: strings.TrimSpace(triggeredBy),
|
TriggeredBy: strings.TrimSpace(triggeredBy),
|
||||||
@@ -191,7 +209,7 @@ func (s *RestoreService) StartSelective(ctx context.Context, backupRecordID uint
|
|||||||
|
|
||||||
// 本地节点:异步执行
|
// 本地节点:异步执行
|
||||||
run := func() {
|
run := func() {
|
||||||
s.executeLocally(context.Background(), restore.ID, task, record, selectedPaths)
|
s.executeLocally(context.Background(), restore.ID, task, record, selectedPaths, targetPath)
|
||||||
}
|
}
|
||||||
s.async(run)
|
s.async(run)
|
||||||
return s.getDetail(ctx, restore.ID)
|
return s.getDetail(ctx, restore.ID)
|
||||||
@@ -218,7 +236,7 @@ func (s *RestoreService) resolveRemoteNode(ctx context.Context, nodeID uint) *mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// executeLocally 在 Master 本地执行恢复。
|
// executeLocally 在 Master 本地执行恢复。
|
||||||
func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, task *model.BackupTask, backupRecord *model.BackupRecord, selectedPaths []string) {
|
func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, task *model.BackupTask, backupRecord *model.BackupRecord, selectedPaths []string, targetPath string) {
|
||||||
s.semaphore <- struct{}{}
|
s.semaphore <- struct{}{}
|
||||||
defer func() { <-s.semaphore }()
|
defer func() { <-s.semaphore }()
|
||||||
|
|
||||||
@@ -247,6 +265,12 @@ func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, tas
|
|||||||
spec.SelectedPaths = selectedPaths
|
spec.SelectedPaths = selectedPaths
|
||||||
logger.Infof("按需恢复:仅恢复选中的 %d 个路径", len(selectedPaths))
|
logger.Infof("按需恢复:仅恢复选中的 %d 个路径", len(selectedPaths))
|
||||||
}
|
}
|
||||||
|
// 恢复到指定目录(已在 StartSelective 校验为文件类型+绝对路径+本机);
|
||||||
|
// 应用于恢复链中的每个归档(全量铺底与差异覆盖均落到该目录)。
|
||||||
|
if targetPath != "" {
|
||||||
|
spec.RestoreTargetPath = targetPath
|
||||||
|
logger.Infof("恢复到指定目录:%s", targetPath)
|
||||||
|
}
|
||||||
runner, runnerErr := s.runnerRegistry.Runner(spec.Type)
|
runner, runnerErr := s.runnerRegistry.Runner(spec.Type)
|
||||||
if runnerErr != nil {
|
if runnerErr != nil {
|
||||||
errMessage = runnerErr.Error()
|
errMessage = runnerErr.Error()
|
||||||
|
|||||||
@@ -269,6 +269,61 @@ func TestRestoreServiceStart_RejectsCorruptedBackup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRestoreServiceStart_RestoresToAlternatePath 验证恢复到指定目录:归档落在指定目录,
|
||||||
|
// 且相对路径被拒绝。
|
||||||
|
func TestRestoreServiceStart_RestoresToAlternatePath(t *testing.T) {
|
||||||
|
h := newRestoreTestHarness(t, false)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
backupDetail, err := h.execution.RunTaskByIDSync(ctx, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunTaskByIDSync: %v", err)
|
||||||
|
}
|
||||||
|
if backupDetail.Status != "success" {
|
||||||
|
t.Fatalf("expected backup success, got %s", backupDetail.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
altDir := filepath.Join(t.TempDir(), "restore-here")
|
||||||
|
|
||||||
|
// 相对路径应被拒绝(且不创建恢复记录)。
|
||||||
|
if _, relErr := h.service.StartSelective(ctx, backupDetail.ID, nil, "relative/path", "tester"); relErr == nil {
|
||||||
|
t.Fatal("relative target path should be rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
h.service.async = func(job func()) {
|
||||||
|
go func() {
|
||||||
|
job()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
detail, err := h.service.StartSelective(ctx, backupDetail.ID, nil, altDir, "tester")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartSelective(altDir): %v", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
t.Fatal("restore did not complete in time")
|
||||||
|
}
|
||||||
|
|
||||||
|
final, err := h.service.Get(ctx, detail.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
if final.Status != model.RestoreRecordStatusSuccess {
|
||||||
|
t.Fatalf("expected success, got %s (err=%s)", final.Status, final.ErrorMessage)
|
||||||
|
}
|
||||||
|
// 源目录 basename 为 "source",归档解压到 altDir/source/index.html。
|
||||||
|
got, err := os.ReadFile(filepath.Join(altDir, "source", "index.html"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read restored file at alternate path: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != "hello-restore" {
|
||||||
|
t.Fatalf("unexpected restored content at alt path: %q", string(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRestoreServiceStart_RemoteNodeEnqueuesCommand(t *testing.T) {
|
func TestRestoreServiceStart_RemoteNodeEnqueuesCommand(t *testing.T) {
|
||||||
h := newRestoreTestHarness(t, true)
|
h := newRestoreTestHarness(t, true)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -193,13 +193,13 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConfirmRestore() {
|
async function handleConfirmRestore(targetPath?: string) {
|
||||||
if (!recordId) {
|
if (!recordId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setRestoreLoading(true)
|
setRestoreLoading(true)
|
||||||
try {
|
try {
|
||||||
const restore = await startRestoreFromBackup(recordId)
|
const restore = await startRestoreFromBackup(recordId, undefined, targetPath)
|
||||||
Message.success('恢复已启动,正在打开日志')
|
Message.success('恢复已启动,正在打开日志')
|
||||||
setRestoreModalVisible(false)
|
setRestoreModalVisible(false)
|
||||||
setRestoreTask(null)
|
setRestoreTask(null)
|
||||||
@@ -345,7 +345,7 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
|||||||
setRestoreModalVisible(false)
|
setRestoreModalVisible(false)
|
||||||
setRestoreTask(null)
|
setRestoreTask(null)
|
||||||
}}
|
}}
|
||||||
onConfirm={() => void handleConfirmRestore()}
|
onConfirm={(targetPath) => void handleConfirmRestore(targetPath)}
|
||||||
/>
|
/>
|
||||||
<BackupRecordContentsModal
|
<BackupRecordContentsModal
|
||||||
visible={contentsVisible}
|
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 { BackupRecordDetail } from '../../types/backup-records'
|
||||||
import type { BackupTaskDetail } from '../../types/backup-tasks'
|
import type { BackupTaskDetail } from '../../types/backup-tasks'
|
||||||
|
|
||||||
@@ -8,21 +9,26 @@ interface RestoreConfirmModalProps {
|
|||||||
backupRecord: BackupRecordDetail | null
|
backupRecord: BackupRecordDetail | null
|
||||||
task: BackupTaskDetail | null
|
task: BackupTaskDetail | null
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onConfirm: () => void
|
onConfirm: (targetPath?: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestoreConfirmModal 展示即将恢复的备份摘要与覆盖风险,强制用户二次确认。
|
// RestoreConfirmModal 展示即将恢复的备份摘要与覆盖风险,强制用户二次确认。
|
||||||
// 恢复是破坏性操作:会覆盖任务配置的源路径/数据库,不可撤销。
|
// 恢复是破坏性操作:会覆盖任务配置的源路径/数据库,不可撤销。
|
||||||
|
// 文件类型 + 本机恢复时,允许指定「恢复到其他目录」以避免覆盖原位置。
|
||||||
export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCancel, onConfirm }: RestoreConfirmModalProps) {
|
export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCancel, onConfirm }: RestoreConfirmModalProps) {
|
||||||
|
const [targetPath, setTargetPath] = useState('')
|
||||||
|
|
||||||
if (!backupRecord || !task) {
|
if (!backupRecord || !task) {
|
||||||
return (
|
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="正在加载任务与备份信息..." />
|
<Alert type="info" content="正在加载任务与备份信息..." />
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreTarget = renderRestoreTarget(task)
|
const restoreTarget = renderRestoreTarget(task)
|
||||||
|
const isLocal = !task.nodeId || task.nodeId === 0
|
||||||
|
const allowAltPath = task.type === 'file' && isLocal
|
||||||
const nodeLabel = task.nodeId && task.nodeId > 0
|
const nodeLabel = task.nodeId && task.nodeId > 0
|
||||||
? (task.nodeName ? `${task.nodeName}(远程节点)` : `节点 #${task.nodeId}`)
|
? (task.nodeName ? `${task.nodeName}(远程节点)` : `节点 #${task.nodeId}`)
|
||||||
: '本机 Master'
|
: '本机 Master'
|
||||||
@@ -35,8 +41,9 @@ export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCa
|
|||||||
cancelText="取消"
|
cancelText="取消"
|
||||||
okButtonProps={{ status: 'danger', loading }}
|
okButtonProps={{ status: 'danger', loading }}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onOk={onConfirm}
|
onOk={() => onConfirm(targetPath.trim() || undefined)}
|
||||||
unmountOnExit
|
unmountOnExit
|
||||||
|
afterClose={() => setTargetPath('')}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||||
<Alert
|
<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: <Tag color="arcoblue" bordered>{task.type.toUpperCase()}</Tag> },
|
||||||
{ label: '执行节点', value: nodeLabel },
|
{ label: '执行节点', value: nodeLabel },
|
||||||
{ label: '源备份', value: backupRecord.fileName || '-' },
|
{ 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>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,9 +39,18 @@ export async function getRestoreRecord(id: number) {
|
|||||||
return unwrapApiEnvelope(response.data)
|
return unwrapApiEnvelope(response.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startRestoreFromBackup 通过源备份记录启动恢复。selectedPaths 非空时为按需(选择性)恢复。
|
// startRestoreFromBackup 通过源备份记录启动恢复。两个可选项互不影响:
|
||||||
export async function startRestoreFromBackup(backupRecordId: number, selectedPaths?: string[]) {
|
// - selectedPaths 非空时为按需(选择性)恢复,仅还原选中的文件/目录;
|
||||||
const body = selectedPaths && selectedPaths.length > 0 ? { selectedPaths } : undefined
|
// - 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)
|
const response = await http.post<ApiEnvelope<RestoreRecordDetail>>(`/backup/records/${backupRecordId}/restore`, body)
|
||||||
return unwrapApiEnvelope(response.data)
|
return unwrapApiEnvelope(response.data)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user