mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 02:52:07 +08:00
512 lines
16 KiB
TypeScript
512 lines
16 KiB
TypeScript
import DeleteOutlined from '@mui/icons-material/DeleteOutlined'
|
|
import DownloadRounded from '@mui/icons-material/DownloadRounded'
|
|
import RefreshRounded from '@mui/icons-material/RefreshRounded'
|
|
import RestoreRounded from '@mui/icons-material/RestoreRounded'
|
|
import {
|
|
Box,
|
|
Button,
|
|
IconButton,
|
|
List,
|
|
ListItem,
|
|
ListItemText,
|
|
ListSubheader,
|
|
Stack,
|
|
Tab,
|
|
Tabs,
|
|
Typography,
|
|
} from '@mui/material'
|
|
import { save } from '@tauri-apps/plugin-dialog'
|
|
import { useLockFn } from 'ahooks'
|
|
import dayjs from 'dayjs'
|
|
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
import { BaseDialog, BaseLoadingOverlay } from '@/components/base'
|
|
import { useVerge } from '@/hooks/use-verge'
|
|
import {
|
|
deleteLocalBackup,
|
|
deleteWebdavBackup,
|
|
exportLocalBackup,
|
|
listLocalBackup,
|
|
listWebDavBackup,
|
|
restartApp,
|
|
restoreLocalBackup,
|
|
restoreWebDavBackup,
|
|
} from '@/services/cmds'
|
|
import { mutate } from '@/services/mutate'
|
|
import { showNotice } from '@/services/notice-service'
|
|
import {
|
|
buildWebdavSignature,
|
|
getWebdavStatus,
|
|
setWebdavStatus,
|
|
} from '@/services/webdav-status'
|
|
|
|
dayjs.extend(customParseFormat)
|
|
dayjs.extend(relativeTime)
|
|
|
|
const DATE_FORMAT = 'YYYY-MM-DD_HH-mm-ss'
|
|
const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/
|
|
|
|
type BackupSource = 'local' | 'webdav'
|
|
type PendingConfirmation = {
|
|
action: 'delete' | 'restore'
|
|
filename: string
|
|
source: BackupSource
|
|
} | null
|
|
|
|
interface BackupHistoryViewerProps {
|
|
open: boolean
|
|
source: BackupSource
|
|
page: number
|
|
onSourceChange: (source: BackupSource) => void
|
|
onPageChange: (page: number) => void
|
|
onClose: () => void
|
|
}
|
|
|
|
interface BackupRow {
|
|
filename: string
|
|
platform: string
|
|
backup_time: dayjs.Dayjs | null
|
|
display_time: string
|
|
sort_value: number
|
|
}
|
|
|
|
export const BackupHistoryViewer = ({
|
|
open,
|
|
source,
|
|
page,
|
|
onSourceChange,
|
|
onPageChange,
|
|
onClose,
|
|
}: BackupHistoryViewerProps) => {
|
|
const { t } = useTranslation()
|
|
const { verge } = useVerge()
|
|
const [rows, setRows] = useState<BackupRow[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [isRestoring, setIsRestoring] = useState(false)
|
|
const [isRestarting, setIsRestarting] = useState(false)
|
|
const [isConfirming, setIsConfirming] = useState(false)
|
|
const [pendingConfirmation, setPendingConfirmation] =
|
|
useState<PendingConfirmation>(null)
|
|
const isLocal = source === 'local'
|
|
const isWebDavConfigured = Boolean(
|
|
verge?.webdav_url && verge?.webdav_username && verge?.webdav_password,
|
|
)
|
|
const webdavSignature = buildWebdavSignature(verge)
|
|
const webdavStatus = getWebdavStatus(webdavSignature)
|
|
const shouldSkipWebDav = !isLocal && !isWebDavConfigured
|
|
const pageSize = 8
|
|
const isBusy = loading || isRestoring || isRestarting || isConfirming
|
|
|
|
const buildRow = useCallback(
|
|
(item: ILocalBackupFile | IWebDavFile): BackupRow | null => {
|
|
const { filename, last_modified } = item
|
|
if (!filename.toLowerCase().endsWith('.zip')) return null
|
|
|
|
const platform =
|
|
(filename.includes('-') && filename.split('-')[0]) ||
|
|
t('settings.modals.backup.history.unknownPlatform', {
|
|
defaultValue: 'unknown',
|
|
})
|
|
const match = filename.match(FILENAME_PATTERN)
|
|
const parsedFromName = match ? dayjs(match[0], DATE_FORMAT, true) : null
|
|
const parsedFromModified =
|
|
last_modified && dayjs(last_modified).isValid()
|
|
? dayjs(last_modified)
|
|
: null
|
|
const backupTime = parsedFromName?.isValid()
|
|
? parsedFromName
|
|
: parsedFromModified
|
|
|
|
return {
|
|
filename,
|
|
platform,
|
|
backup_time: backupTime ?? null,
|
|
display_time:
|
|
backupTime?.format('YYYY-MM-DD HH:mm') ??
|
|
parsedFromModified?.format('YYYY-MM-DD HH:mm') ??
|
|
t('settings.modals.backup.history.unknownTime', {
|
|
defaultValue: 'Unknown time',
|
|
}),
|
|
sort_value:
|
|
backupTime?.valueOf() ??
|
|
parsedFromModified?.valueOf() ??
|
|
Number.NEGATIVE_INFINITY,
|
|
}
|
|
},
|
|
[t],
|
|
)
|
|
|
|
const fetchRows = useCallback(
|
|
async (options?: { force?: boolean }) => {
|
|
if (!open) return
|
|
if (shouldSkipWebDav) {
|
|
setRows([])
|
|
return
|
|
}
|
|
// Read the status here: as a dependency, the write below re-runs the fetch.
|
|
if (
|
|
!isLocal &&
|
|
getWebdavStatus(webdavSignature) === 'failed' &&
|
|
!options?.force
|
|
) {
|
|
setRows([])
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
try {
|
|
const list = isLocal
|
|
? await listLocalBackup()
|
|
: await listWebDavBackup()
|
|
if (!isLocal) {
|
|
setWebdavStatus(webdavSignature, 'ready')
|
|
}
|
|
setRows(
|
|
list
|
|
.map((item) => buildRow(item))
|
|
.filter((item): item is BackupRow => item !== null)
|
|
.sort((a, b) =>
|
|
a.sort_value === b.sort_value
|
|
? b.filename.localeCompare(a.filename)
|
|
: b.sort_value - a.sort_value,
|
|
),
|
|
)
|
|
} catch (error) {
|
|
if (!isLocal) {
|
|
setWebdavStatus(webdavSignature, 'failed')
|
|
}
|
|
console.error(error)
|
|
setRows([])
|
|
showNotice.error(error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
},
|
|
[buildRow, isLocal, open, shouldSkipWebDav, webdavSignature],
|
|
)
|
|
|
|
useEffect(() => {
|
|
void fetchRows()
|
|
}, [fetchRows])
|
|
|
|
const total = rows.length
|
|
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
|
const currentPage = Math.min(page, pageCount - 1)
|
|
const pagedRows = rows.slice(
|
|
currentPage * pageSize,
|
|
currentPage * pageSize + pageSize,
|
|
)
|
|
|
|
const summary = useMemo(() => {
|
|
if (shouldSkipWebDav || (!isLocal && webdavStatus === 'failed')) {
|
|
return t('settings.modals.backup.manual.webdav')
|
|
}
|
|
if (!total) return t('settings.modals.backup.history.empty')
|
|
const recent =
|
|
rows[0]?.backup_time?.fromNow() ?? rows[0]?.display_time ?? ''
|
|
return t('settings.modals.backup.history.summary', {
|
|
count: total,
|
|
recent,
|
|
})
|
|
}, [isLocal, rows, shouldSkipWebDav, t, total, webdavStatus])
|
|
|
|
const handleDelete = (filename: string) => {
|
|
if (isRestarting) return
|
|
setPendingConfirmation({ action: 'delete', filename, source })
|
|
}
|
|
|
|
const handleRestore = (filename: string) => {
|
|
if (isRestoring || isRestarting) return
|
|
setPendingConfirmation({ action: 'restore', filename, source })
|
|
}
|
|
|
|
const handleConfirmAction = useLockFn(async () => {
|
|
if (!pendingConfirmation) return
|
|
const { action, filename, source: actionSource } = pendingConfirmation
|
|
const actionIsLocal = actionSource === 'local'
|
|
setIsConfirming(true)
|
|
if (action === 'restore') {
|
|
setIsRestoring(true)
|
|
}
|
|
try {
|
|
if (action === 'delete') {
|
|
if (actionIsLocal) {
|
|
await mutate(() => deleteLocalBackup(filename), {
|
|
id: 'delete-local-backup',
|
|
errorNotice: false,
|
|
})
|
|
} else {
|
|
await mutate(() => deleteWebdavBackup(filename), {
|
|
id: 'delete-webdav-backup',
|
|
errorNotice: false,
|
|
})
|
|
}
|
|
setPendingConfirmation(null)
|
|
await fetchRows()
|
|
} else {
|
|
if (actionIsLocal) {
|
|
await mutate(() => restoreLocalBackup(filename), {
|
|
id: 'restore-local-backup',
|
|
errorNotice: false,
|
|
})
|
|
} else {
|
|
await mutate(() => restoreWebDavBackup(filename), {
|
|
id: 'restore-webdav-backup',
|
|
errorNotice: false,
|
|
})
|
|
}
|
|
setPendingConfirmation(null)
|
|
showNotice.success('settings.modals.backup.messages.restoreSuccess')
|
|
setIsRestarting(true)
|
|
window.setTimeout(() => {
|
|
void mutate(() => restartApp(), {
|
|
id: 'restart-app',
|
|
errorNotice: false,
|
|
}).catch((err: unknown) => {
|
|
setIsRestarting(false)
|
|
showNotice.error(err)
|
|
})
|
|
}, 1000)
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
showNotice.error(error)
|
|
} finally {
|
|
setIsConfirming(false)
|
|
setIsRestoring(false)
|
|
}
|
|
})
|
|
|
|
const handleExport = useLockFn(async (filename: string) => {
|
|
if (isRestarting) return
|
|
if (!isLocal) return
|
|
const savePath = await save({ defaultPath: filename })
|
|
if (!savePath || Array.isArray(savePath)) return
|
|
try {
|
|
await mutate(() => exportLocalBackup(filename, savePath), {
|
|
id: 'export-local-backup',
|
|
errorNotice: false,
|
|
successNotice: 'settings.modals.backup.messages.localBackupExported',
|
|
})
|
|
} catch (ignoreError: unknown) {
|
|
showNotice.error(
|
|
'settings.modals.backup.messages.localBackupExportFailed',
|
|
)
|
|
}
|
|
})
|
|
|
|
const handleRefresh = () => {
|
|
if (isRestarting) return
|
|
void fetchRows({ force: true })
|
|
}
|
|
|
|
const closeConfirmDialog = () => {
|
|
if (isConfirming) return
|
|
setPendingConfirmation(null)
|
|
}
|
|
|
|
const confirmTitle =
|
|
pendingConfirmation?.action === 'delete'
|
|
? t('settings.modals.backup.actions.deleteBackup')
|
|
: t('settings.modals.backup.actions.restoreBackup')
|
|
const confirmMessage =
|
|
pendingConfirmation?.action === 'delete'
|
|
? t('settings.modals.backup.messages.confirmDelete')
|
|
: t('settings.modals.backup.messages.confirmRestore')
|
|
|
|
return (
|
|
<BaseDialog
|
|
open={open}
|
|
title={t('settings.modals.backup.history.title')}
|
|
contentSx={{ width: 520 }}
|
|
disableOk
|
|
cancelBtn={t('shared.actions.close')}
|
|
onCancel={onClose}
|
|
onClose={onClose}
|
|
>
|
|
<Box
|
|
data-settings-dialog="backup-history"
|
|
data-search-ready={!loading}
|
|
data-setting-target="backup-history"
|
|
tabIndex={-1}
|
|
sx={{ position: 'relative', minHeight: 320 }}
|
|
>
|
|
<BaseLoadingOverlay isLoading={isBusy} />
|
|
<Stack spacing={2}>
|
|
<Stack
|
|
direction="row"
|
|
sx={{ alignItems: 'center', justifyContent: 'space-between' }}
|
|
>
|
|
<Tabs
|
|
value={source}
|
|
onChange={(_, val) => {
|
|
if (isBusy) return
|
|
onSourceChange(val as BackupSource)
|
|
onPageChange(0)
|
|
}}
|
|
textColor="primary"
|
|
indicatorColor="primary"
|
|
>
|
|
<Tab
|
|
value="local"
|
|
label={t('settings.modals.backup.tabs.local')}
|
|
disabled={isBusy}
|
|
sx={{ px: 2 }}
|
|
/>
|
|
<Tab
|
|
value="webdav"
|
|
label={t('settings.modals.backup.tabs.webdav')}
|
|
disabled={isBusy}
|
|
sx={{ px: 2 }}
|
|
/>
|
|
</Tabs>
|
|
<IconButton size="small" onClick={handleRefresh} disabled={isBusy}>
|
|
<RefreshRounded fontSize="small" />
|
|
</IconButton>
|
|
</Stack>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{summary}
|
|
</Typography>
|
|
|
|
<List
|
|
disablePadding
|
|
subheader={
|
|
<ListSubheader disableSticky>
|
|
{t('settings.modals.backup.history.title')}
|
|
</ListSubheader>
|
|
}
|
|
>
|
|
{pagedRows.length === 0 ? (
|
|
<ListItem>
|
|
<ListItemText
|
|
primary={t('settings.modals.backup.history.empty') || ''}
|
|
/>
|
|
</ListItem>
|
|
) : (
|
|
pagedRows.map((row) => (
|
|
<ListItem key={`${row.platform}-${row.filename}`} divider>
|
|
<ListItemText
|
|
primary={
|
|
<Typography
|
|
variant="body2"
|
|
sx={{ wordBreak: 'break-all', fontWeight: 500 }}
|
|
>
|
|
{row.filename}
|
|
</Typography>
|
|
}
|
|
secondary={
|
|
<Stack
|
|
direction="row"
|
|
spacing={1.5}
|
|
sx={{
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
}}
|
|
>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{`${row.platform} · ${row.display_time}`}
|
|
</Typography>
|
|
<Stack
|
|
direction="row"
|
|
spacing={0.5}
|
|
sx={{ alignItems: 'center' }}
|
|
>
|
|
{isLocal && (
|
|
<IconButton
|
|
size="small"
|
|
disabled={isBusy}
|
|
data-setting-target="backup-history-export"
|
|
onClick={() => handleExport(row.filename)}
|
|
>
|
|
<DownloadRounded fontSize="small" />
|
|
</IconButton>
|
|
)}
|
|
<IconButton
|
|
size="small"
|
|
disabled={isBusy}
|
|
data-setting-target="backup-history-delete"
|
|
onClick={() => handleDelete(row.filename)}
|
|
>
|
|
<DeleteOutlined fontSize="small" />
|
|
</IconButton>
|
|
<IconButton
|
|
size="small"
|
|
disabled={isBusy}
|
|
data-setting-target="backup-history-restore"
|
|
onClick={() => handleRestore(row.filename)}
|
|
>
|
|
<RestoreRounded fontSize="small" />
|
|
</IconButton>
|
|
</Stack>
|
|
</Stack>
|
|
}
|
|
/>
|
|
</ListItem>
|
|
))
|
|
)}
|
|
</List>
|
|
|
|
{pageCount > 1 && (
|
|
<Stack
|
|
direction="row"
|
|
spacing={1}
|
|
sx={{ justifyContent: 'flex-end', alignItems: 'center' }}
|
|
>
|
|
<Typography variant="caption">
|
|
{currentPage + 1} / {pageCount}
|
|
</Typography>
|
|
<Stack direction="row" spacing={1}>
|
|
<Button
|
|
size="small"
|
|
variant="text"
|
|
disabled={isBusy || currentPage === 0}
|
|
onClick={() => onPageChange(Math.max(0, currentPage - 1))}
|
|
>
|
|
{t('shared.actions.previous')}
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
variant="text"
|
|
disabled={isBusy || currentPage >= pageCount - 1}
|
|
onClick={() =>
|
|
onPageChange(Math.min(pageCount - 1, currentPage + 1))
|
|
}
|
|
>
|
|
{t('shared.actions.next')}
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
<BaseDialog
|
|
open={pendingConfirmation !== null}
|
|
title={confirmTitle}
|
|
okBtn={t('shared.actions.confirm')}
|
|
cancelBtn={t('shared.actions.cancel')}
|
|
contentSx={{ width: { xs: 320, sm: 420 } }}
|
|
loading={isConfirming}
|
|
onCancel={closeConfirmDialog}
|
|
onClose={closeConfirmDialog}
|
|
onOk={handleConfirmAction}
|
|
>
|
|
<Typography variant="body2" sx={{ wordBreak: 'break-word' }}>
|
|
{confirmMessage}
|
|
</Typography>
|
|
{pendingConfirmation?.filename && (
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{ display: 'block', mt: 1, wordBreak: 'break-all' }}
|
|
>
|
|
{pendingConfirmation.filename}
|
|
</Typography>
|
|
)}
|
|
</BaseDialog>
|
|
</BaseDialog>
|
|
)
|
|
}
|