mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-04 06:57:30 +08:00
Remove obsolete implementations, centralize background task ownership and terminal-state recovery, consolidate frontend routing and log streaming, and enforce project-wide verification in CI.
154 lines
4.8 KiB
TypeScript
154 lines
4.8 KiB
TypeScript
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'
|
||
import { resolveErrorMessage } from '../../utils/error'
|
||
import { formatBytes } from '../../utils/format'
|
||
|
||
interface BackupRecordContentsModalProps {
|
||
visible: boolean
|
||
recordId?: number
|
||
onClose: () => void
|
||
// onRestoreSelected 提供时启用按需恢复:勾选条目后回调选中的归档路径。
|
||
onRestoreSelected?: (paths: string[]) => void
|
||
}
|
||
|
||
// BackupRecordContentsModal 浏览某次备份捕获的文件清单(只读)。
|
||
// 数据来源于全量备份记录的清单,无需下载归档,秒级展示并支持按路径筛选。
|
||
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) {
|
||
return
|
||
}
|
||
let active = true
|
||
setLoading(true)
|
||
setError('')
|
||
setKeyword('')
|
||
setContents(null)
|
||
setSelectedKeys([])
|
||
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 (
|
||
<Modal
|
||
visible={visible}
|
||
title="备份内容"
|
||
footer={null}
|
||
onCancel={onClose}
|
||
unmountOnExit
|
||
style={{ width: 760 }}
|
||
>
|
||
{loading ? (
|
||
<Spin style={{ display: 'block', textAlign: 'center', padding: 40 }} />
|
||
) : error ? (
|
||
<Alert type="warning" content={error} />
|
||
) : contents ? (
|
||
<div>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
共 {contents.total} 个条目
|
||
{contents.truncated ? `(清单较大,仅展示前 ${contents.entries.length} 个)` : ''}
|
||
{contents.basedOnFull ? `;差异备份,清单取自基线全量 #${contents.basedOnFull}` : ''}
|
||
</Typography.Text>
|
||
<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={[
|
||
{
|
||
title: '路径',
|
||
dataIndex: 'path',
|
||
render: (_: unknown, row: BackupRecordContentEntry) => (
|
||
<span>
|
||
{row.isDir ? (
|
||
<Tag size="small" color="arcoblue">
|
||
目录
|
||
</Tag>
|
||
) : null}{' '}
|
||
{row.path}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
title: '大小',
|
||
dataIndex: 'size',
|
||
width: 120,
|
||
align: 'right',
|
||
render: (_: unknown, row: BackupRecordContentEntry) =>
|
||
row.isDir ? '-' : formatBytes(row.size),
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</Modal>
|
||
)
|
||
}
|