diff --git a/server/internal/http/backup_record_handler.go b/server/internal/http/backup_record_handler.go index fd7a055..9087482 100644 --- a/server/internal/http/backup_record_handler.go +++ b/server/internal/http/backup_record_handler.go @@ -52,6 +52,20 @@ func (h *BackupRecordHandler) Get(c *gin.Context) { response.Success(c, item) } +// Contents 返回备份记录的文件清单(内容浏览,只读)。 +func (h *BackupRecordHandler) Contents(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + contents, err := h.service.ListContents(c.Request.Context(), id) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, contents) +} + func (h *BackupRecordHandler) StreamLogs(c *gin.Context) { id, ok := parseUintParam(c, "id") if !ok { diff --git a/server/internal/http/router.go b/server/internal/http/router.go index 6745ebd..ba5bf9d 100644 --- a/server/internal/http/router.go +++ b/server/internal/http/router.go @@ -167,6 +167,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine { backupRecords.GET("/:id", backupRecordHandler.Get) backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs) backupRecords.GET("/:id/download", backupRecordHandler.Download) + backupRecords.GET("/:id/contents", backupRecordHandler.Contents) backupRecords.POST("/:id/restore", RequireNotViewer(), backupRecordHandler.Restore) backupRecords.POST("/batch-delete", RequireNotViewer(), backupRecordHandler.BatchDelete) backupRecords.DELETE("/:id", RequireNotViewer(), backupRecordHandler.Delete) diff --git a/server/internal/service/backup_record_service.go b/server/internal/service/backup_record_service.go index 4e6a103..e4d7246 100644 --- a/server/internal/service/backup_record_service.go +++ b/server/internal/service/backup_record_service.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "sort" "strings" "time" @@ -80,6 +81,64 @@ func (s *BackupRecordService) Get(ctx context.Context, id uint) (*BackupRecordDe return toBackupRecordDetail(item, s.logHub), nil } +// BackupContentEntry 描述备份内单个条目(文件或目录),用于内容浏览。 +type BackupContentEntry struct { + Path string `json:"path"` + Size int64 `json:"size"` + IsDir bool `json:"isDir"` +} + +// BackupRecordContents 是一次备份的内容清单视图。 +type BackupRecordContents struct { + RecordID uint `json:"recordId"` + Total int `json:"total"` + Truncated bool `json:"truncated"` + BasedOnFull uint `json:"basedOnFull,omitempty"` // 差异记录时,清单取自该基线全量 + Entries []BackupContentEntry `json:"entries"` +} + +const backupContentsMaxEntries = 10000 + +// ListContents 返回某备份记录的文件清单(仅文件类型的新全量备份会记录清单)。 +// 差异记录回退到其基线全量的清单,近似展示恢复后的目录结构。无清单时返回明确错误。 +func (s *BackupRecordService) ListContents(ctx context.Context, id uint) (*BackupRecordContents, error) { + item, err := s.records.FindByID(ctx, id) + if err != nil { + return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录", err) + } + if item == nil { + return nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", nil) + } + manifestJSON := item.Manifest + basedOnFull := uint(0) + if strings.TrimSpace(manifestJSON) == "" && item.BaseRecordID != 0 { + if base, baseErr := s.records.FindByID(ctx, item.BaseRecordID); baseErr == nil && base != nil { + manifestJSON = base.Manifest + basedOnFull = base.ID + } + } + if strings.TrimSpace(manifestJSON) == "" { + return nil, apperror.New(422, "BACKUP_CONTENTS_UNAVAILABLE", "该备份未记录文件清单(仅文件类型的新全量备份支持内容浏览),请重新执行一次全量备份后再试。", nil) + } + manifest, decErr := backup.DecodeManifest([]byte(manifestJSON)) + if decErr != nil { + return nil, apperror.Internal("BACKUP_CONTENTS_DECODE_FAILED", "解析备份清单失败", decErr) + } + entries := manifest.Entries + sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path }) + total := len(entries) + truncated := false + if total > backupContentsMaxEntries { + entries = entries[:backupContentsMaxEntries] + truncated = true + } + result := &BackupRecordContents{RecordID: item.ID, Total: total, Truncated: truncated, BasedOnFull: basedOnFull, Entries: make([]BackupContentEntry, 0, len(entries))} + for _, e := range entries { + result.Entries = append(result.Entries, BackupContentEntry{Path: e.Path, Size: e.Size, IsDir: e.IsDir}) + } + return result, nil +} + func (s *BackupRecordService) SubscribeLogs(ctx context.Context, id uint, buffer int) (<-chan backup.LogEvent, func(), error) { item, err := s.records.FindByID(ctx, id) if err != nil { diff --git a/web/src/components/backup-records/BackupRecordContentsModal.tsx b/web/src/components/backup-records/BackupRecordContentsModal.tsx new file mode 100644 index 0000000..0520579 --- /dev/null +++ b/web/src/components/backup-records/BackupRecordContentsModal.tsx @@ -0,0 +1,109 @@ +import { Alert, 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' +import { resolveErrorMessage } from '../../utils/error' +import { formatBytes } from '../../utils/format' + +interface BackupRecordContentsModalProps { + visible: boolean + recordId?: number + onClose: () => void +} + +// BackupRecordContentsModal 浏览某次备份捕获的文件清单(只读)。 +// 数据来源于全量备份记录的清单,无需下载归档,秒级展示并支持按路径筛选。 +export function BackupRecordContentsModal({ visible, recordId, onClose }: BackupRecordContentsModalProps) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [contents, setContents] = useState(null) + const [keyword, setKeyword] = useState('') + + useEffect(() => { + if (!visible || !recordId) { + return + } + let active = true + setLoading(true) + setError('') + setKeyword('') + setContents(null) + void (async () => { + try { + const data = await getBackupRecordContents(recordId) + if (active) { + setContents(data) + } + } catch (e) { + if (active) { + setError(resolveErrorMessage(e, '加载备份内容失败')) + } + } finally { + if (active) { + setLoading(false) + } + } + })() + return () => { + active = false + } + }, [visible, recordId]) + + const filtered = useMemo(() => { + const entries = contents?.entries ?? [] + const kw = keyword.trim().toLowerCase() + if (!kw) { + return entries + } + return entries.filter((e) => e.path.toLowerCase().includes(kw)) + }, [contents, keyword]) + + return ( + + {loading ? ( + + ) : error ? ( + + ) : contents ? ( +
+ + 共 {contents.total} 个条目 + {contents.truncated ? `(清单较大,仅展示前 ${contents.entries.length} 个)` : ''} + {contents.basedOnFull ? `;差异备份,清单取自基线全量 #${contents.basedOnFull}` : ''} + + + ( + + {row.isDir ? ( + + 目录 + + ) : null}{' '} + {row.path} + + ), + }, + { + title: '大小', + dataIndex: 'size', + width: 120, + align: 'right', + render: (_: unknown, row: BackupRecordContentEntry) => (row.isDir ? '-' : formatBytes(row.size)), + }, + ]} + /> + + ) : null} + + ) +} diff --git a/web/src/components/backup-records/BackupRecordLogDrawer.tsx b/web/src/components/backup-records/BackupRecordLogDrawer.tsx index 1bc1540..b0c370f 100644 --- a/web/src/components/backup-records/BackupRecordLogDrawer.tsx +++ b/web/src/components/backup-records/BackupRecordLogDrawer.tsx @@ -12,6 +12,7 @@ import type { BackupTaskDetail } from '../../types/backup-tasks' import { resolveErrorMessage } from '../../utils/error' import { formatBytes, formatDateTime, formatDuration } from '../../utils/format' import { RestoreConfirmModal } from '../restore-records/RestoreConfirmModal' +import { BackupRecordContentsModal } from './BackupRecordContentsModal' interface BackupRecordLogDrawerProps { visible: boolean @@ -53,6 +54,7 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged } const [restoreLoading, setRestoreLoading] = useState(false) const [restorePreparing, setRestorePreparing] = useState(false) const [verifyLoading, setVerifyLoading] = useState(false) + const [contentsVisible, setContentsVisible] = useState(false) useEffect(() => { if (!visible || !recordId) { @@ -268,6 +270,7 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged } + {writable && (