feat(backup): 备份保留锁定 / 法律保留(豁免清理删除 + 记录页锁定) (#84)

新增保留锁定:锁定的备份豁免保留期清理与手动删除(迁移基线/合规快照/取证)。model+Locked、retention 剔除锁定记录、DeleteRecord 拒绝删除、PUT /backup/records/:id/lock、记录页锁定/解锁操作与标识。go test、tsc+vite、运行时路由验证均通过。
This commit is contained in:
Wu Qing
2026-05-27 13:59:05 +08:00
committed by GitHub
parent f807ce10e6
commit 386f12a11b
11 changed files with 266 additions and 26 deletions

View File

@@ -2,7 +2,7 @@ import { Button, Card, Empty, Message, Select, Space, Table, Tag, Typography } f
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { BackupRecordLogDrawer } from '../../components/backup-records/BackupRecordLogDrawer'
import { listBackupRecords } from '../../services/backup-records'
import { listBackupRecords, setBackupRecordLock } from '../../services/backup-records'
import { listBackupTasks } from '../../services/backup-tasks'
import type { BackupRecordStatus, BackupRecordSummary } from '../../types/backup-records'
import type { BackupTaskSummary } from '../../types/backup-tasks'
@@ -33,6 +33,7 @@ export function BackupRecordsPage() {
const [tasks, setTasks] = useState<BackupTaskSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [lockBusyId, setLockBusyId] = useState<number | null>(null)
const selectedTaskId = Number(searchParams.get('taskId') ?? 0) || undefined
const selectedRecordId = Number(searchParams.get('recordId') ?? 0) || undefined
@@ -64,6 +65,19 @@ export function BackupRecordsPage() {
void loadData()
}, [loadData])
async function handleToggleLock(record: BackupRecordSummary) {
setLockBusyId(record.id)
try {
await setBackupRecordLock(record.id, !record.locked)
Message.success(record.locked ? '已解除保留锁定' : '已锁定:该备份将豁免保留清理与删除')
await loadData()
} catch (e) {
Message.error(resolveErrorMessage(e, '操作失败'))
} finally {
setLockBusyId(null)
}
}
function updateSearchParam(key: 'taskId' | 'status' | 'recordId', value?: string) {
const nextParams = new URLSearchParams(searchParams)
if (!value || value === '0') {
@@ -96,7 +110,10 @@ export function BackupRecordsPage() {
dataIndex: 'fileName',
render: (_: unknown, record: BackupRecordSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text>{record.fileName || '-'}</Typography.Text>
<Space size={4}>
<Typography.Text>{record.fileName || '-'}</Typography.Text>
{record.locked && <Tag color="orange" size="small" bordered></Tag>}
</Space>
<Typography.Text type="secondary">{formatBytes(record.fileSize)}</Typography.Text>
{record.checksum && (
<Typography.Text type="secondary" copyable style={{ fontSize: 11 }}>
@@ -129,11 +146,24 @@ export function BackupRecordsPage() {
{
title: '操作',
dataIndex: 'actions',
width: 120,
width: 180,
render: (_: unknown, record: BackupRecordSummary) => (
<Button size="small" type="text" onClick={() => updateSearchParam('recordId', String(record.id))}>
</Button>
<Space size={4}>
<Button size="small" type="text" onClick={() => updateSearchParam('recordId', String(record.id))}>
</Button>
{record.status === 'success' && (
<Button
size="small"
type="text"
status={record.locked ? 'warning' : 'default'}
loading={lockBusyId === record.id}
onClick={() => void handleToggleLock(record)}
>
{record.locked ? '解锁' : '锁定'}
</Button>
)}
</Space>
),
},
]

View File

@@ -89,6 +89,12 @@ export async function deleteBackupRecord(id: number) {
return unwrapApiEnvelope(response.data)
}
// setBackupRecordLock 设置/解除备份记录的保留锁定(法律保留)。
export async function setBackupRecordLock(id: number, locked: boolean) {
const response = await http.put<ApiEnvelope<BackupRecordDetail>>(`/backup/records/${id}/lock`, { locked })
return unwrapApiEnvelope(response.data)
}
export function streamBackupRecordLogs(recordId: number, handlers: RecordLogStreamHandlers) {
const controller = new AbortController()

View File

@@ -25,6 +25,7 @@ export interface BackupRecordSummary {
errorMessage: string
startedAt: string
completedAt?: string
locked: boolean
}
export interface StorageUploadResultItem {