fix: align backup dialogs with in-app modal behavior

This commit is contained in:
wonfen 2026-05-20 10:24:11 +08:00
parent 6f36613103
commit 15de400f2f
No known key found for this signature in database
GPG Key ID: CEAFD6C73AB2001F
11 changed files with 115 additions and 145 deletions

1
.gitignore vendored
View File

@ -17,3 +17,4 @@ CLAUDE.md
.vfox.toml
.vfox/
.claude
AGENTS.md

View File

@ -2,6 +2,8 @@
### 🐞 修复问题
- 备份设置功能异常
<details>
<summary><strong> ✨ 新增功能 </strong></summary>

View File

@ -42,7 +42,6 @@
"@juggle/resize-observer": "^3.4.0",
"@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^9.0.0",
"@mui/lab": "9.0.0-beta.2",
"@mui/material": "^9.0.0",
"@tanstack/react-query": "^5.96.1",
"@tanstack/react-table": "^8.21.3",

View File

@ -32,9 +32,6 @@ importers:
'@mui/icons-material':
specifier: ^9.0.0
version: 9.0.0(@mui/material@9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)
'@mui/lab':
specifier: 9.0.0-beta.2
version: 9.0.0-beta.2(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@mui/material@9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@mui/material':
specifier: ^9.0.0
version: 9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@ -1068,27 +1065,6 @@ packages:
'@types/react':
optional: true
'@mui/lab@9.0.0-beta.2':
resolution: {integrity: sha512-UqykXQCn7HNSExyYBvrFpaaEEmUwHbEgjFDBApEkawVPcBILyYNFhpXoqkwkafiZy+WsvcxIeRF0z4tCgisTRg==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@emotion/react': ^11.5.0
'@emotion/styled': ^11.3.0
'@mui/material': ^9.0.0
'@mui/material-pigment-css': ^9.0.0
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/react':
optional: true
'@emotion/styled':
optional: true
'@mui/material-pigment-css':
optional: true
'@types/react':
optional: true
'@mui/material@9.0.0':
resolution: {integrity: sha512-+VP/oQCDhDR87NQQgXnNBG8dwy6GNuQLnenS1pZvkbn2dKFSxRSRMybTpH9xUxXP+316mlYDy5CSbYtusnCWtw==}
engines: {node: '>=14.0.0'}
@ -4651,22 +4627,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@mui/lab@9.0.0-beta.2(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@mui/material@9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@babel/runtime': 7.29.2
'@mui/material': 9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@mui/system': 9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)
'@mui/types': 9.0.0(@types/react@19.2.14)
'@mui/utils': 9.0.0(@types/react@19.2.14)(react@19.2.5)
clsx: 2.1.1
prop-types: 15.8.1
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)
'@types/react': 19.2.14
'@mui/material@9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@babel/runtime': 7.29.2

View File

@ -1,4 +1,3 @@
import { LoadingButton } from '@mui/lab'
import {
Button,
Dialog,
@ -66,9 +65,9 @@ export const BaseDialog: React.FC<Props> = ({
</Button>
)}
{!disableOk && (
<LoadingButton loading={loading} variant="contained" onClick={onOk}>
<Button loading={loading} variant="contained" onClick={onOk}>
{okBtn}
</LoadingButton>
</Button>
)}
</DialogActions>
)}

View File

@ -1,46 +0,0 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from '@mui/material'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
title: string
message: string
onClose: () => void
onConfirm: () => void
}
export const ConfirmViewer = (props: Props) => {
const { open, title, message, onClose, onConfirm } = props
const { t } = useTranslation()
useEffect(() => {
if (!open) return
}, [open])
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogContent sx={{ pb: 1, userSelect: 'text' }}>
{message}
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="outlined">
{t('shared.actions.cancel')}
</Button>
<Button onClick={onConfirm} variant="contained">
{t('shared.actions.confirm')}
</Button>
</DialogActions>
</Dialog>
)
}

View File

@ -22,7 +22,7 @@ import dayjs from 'dayjs'
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ConfirmViewer } from '@/components/profile/confirm-viewer'
import { BaseDialog } from '@/components/base'
import { EditorViewer } from '@/components/profile/editor-viewer'
import { GroupsEditorViewer } from '@/components/profile/groups-editor-viewer'
import { RulesEditorViewer } from '@/components/profile/rules-editor-viewer'
@ -990,16 +990,23 @@ export const ProfileItem = (props: Props) => {
/>
)}
<ConfirmViewer
<BaseDialog
title={t('profiles.modals.confirmDelete.title')}
message={t('profiles.modals.confirmDelete.message')}
open={confirmOpen}
okBtn={t('shared.actions.confirm')}
cancelBtn={t('shared.actions.cancel')}
contentSx={{ width: { xs: 320, sm: 420 }, userSelect: 'text' }}
onCancel={() => setConfirmOpen(false)}
onClose={() => setConfirmOpen(false)}
onConfirm={() => {
onOk={() => {
onDelete()
setConfirmOpen(false)
}}
/>
>
<Typography variant="body2" sx={{ wordBreak: 'break-word' }}>
{t('profiles.modals.confirmDelete.message')}
</Typography>
</BaseDialog>
{qrOpen && itemData.url && (
<QrViewer
open={true}

View File

@ -49,6 +49,11 @@ 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
@ -67,11 +72,6 @@ interface BackupRow {
sort_value: number
}
const confirmAsync = async (message: string) => {
const fn = window.confirm as (msg?: string) => boolean
return fn(message)
}
export const BackupHistoryViewer = ({
open,
source,
@ -86,6 +86,9 @@ export const BackupHistoryViewer = ({
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,
@ -94,7 +97,7 @@ export const BackupHistoryViewer = ({
const webdavStatus = getWebdavStatus(webdavSignature)
const shouldSkipWebDav = !isLocal && !isWebDavConfigured
const pageSize = 8
const isBusy = loading || isRestoring || isRestarting
const isBusy = loading || isRestoring || isRestarting || isConfirming
const buildRow = useCallback(
(item: ILocalBackupFile | IWebDavFile): BackupRow | null => {
@ -204,45 +207,54 @@ export const BackupHistoryViewer = ({
})
}, [isLocal, rows, shouldSkipWebDav, t, total, webdavStatus])
const handleDelete = useLockFn(async (filename: string) => {
const handleDelete = (filename: string) => {
if (isRestarting) return
if (
!(await confirmAsync(t('settings.modals.backup.messages.confirmDelete')))
)
return
if (isLocal) {
await deleteLocalBackup(filename)
} else {
await deleteWebdavBackup(filename)
}
await fetchRows()
})
setPendingConfirmation({ action: 'delete', filename, source })
}
const handleRestore = useLockFn(async (filename: string) => {
const handleRestore = (filename: string) => {
if (isRestoring || isRestarting) return
if (
!(await confirmAsync(t('settings.modals.backup.messages.confirmRestore')))
)
return
setIsRestoring(true)
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 (isLocal) {
await restoreLocalBackup(filename)
if (action === 'delete') {
if (actionIsLocal) {
await deleteLocalBackup(filename)
} else {
await deleteWebdavBackup(filename)
}
setPendingConfirmation(null)
await fetchRows()
} else {
await restoreWebDavBackup(filename)
if (actionIsLocal) {
await restoreLocalBackup(filename)
} else {
await restoreWebDavBackup(filename)
}
setPendingConfirmation(null)
showNotice.success('settings.modals.backup.messages.restoreSuccess')
setIsRestarting(true)
window.setTimeout(() => {
void restartApp().catch((err: unknown) => {
setIsRestarting(false)
showNotice.error(err)
})
}, 1000)
}
showNotice.success('settings.modals.backup.messages.restoreSuccess')
setIsRestarting(true)
window.setTimeout(() => {
void restartApp().catch((err: unknown) => {
setIsRestarting(false)
showNotice.error(err)
})
}, 1000)
} catch (error) {
console.error(error)
showNotice.error(error)
} finally {
setIsConfirming(false)
setIsRestoring(false)
}
})
@ -267,6 +279,20 @@ export const BackupHistoryViewer = ({
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}
@ -423,6 +449,30 @@ export const BackupHistoryViewer = ({
)}
</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>
)
}

View File

@ -1,4 +1,3 @@
import { LoadingButton } from '@mui/lab'
import {
Button,
List,
@ -156,7 +155,7 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
title: t('settings.modals.backup.tabs.local'),
description: t('settings.modals.backup.manual.local'),
actions: [
<LoadingButton
<Button
key="backup"
variant="contained"
size="small"
@ -165,7 +164,7 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
onClick={() => handleBackup('local')}
>
{t('settings.modals.backup.actions.backup')}
</LoadingButton>,
</Button>,
<Button
key="history"
variant="outlined"
@ -175,7 +174,7 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
>
{t('settings.modals.backup.actions.viewHistory')}
</Button>,
<LoadingButton
<Button
key="import"
variant="text"
size="small"
@ -184,7 +183,7 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
onClick={() => handleImport()}
>
{t('settings.modals.backup.actions.importBackup')}
</LoadingButton>,
</Button>,
],
},
{
@ -192,7 +191,7 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
title: t('settings.modals.backup.tabs.webdav'),
description: t('settings.modals.backup.manual.webdav'),
actions: [
<LoadingButton
<Button
key="backup"
variant="contained"
size="small"
@ -200,7 +199,7 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
onClick={() => handleBackup('webdav')}
>
{t('settings.modals.backup.actions.backup')}
</LoadingButton>,
</Button>,
<Button
key="history"
variant="outlined"

View File

@ -2,9 +2,9 @@ import {
RestartAltRounded,
SwitchAccessShortcutRounded,
} from '@mui/icons-material'
import { LoadingButton } from '@mui/lab'
import {
Box,
Button,
Chip,
CircularProgress,
List,
@ -120,7 +120,7 @@ export function ClashCoreViewer({ ref }: { ref?: Ref<DialogRef> }) {
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
{t('settings.sections.clash.form.fields.clashCore')}
<Box>
<LoadingButton
<Button
variant="contained"
size="small"
startIcon={<SwitchAccessShortcutRounded />}
@ -131,8 +131,8 @@ export function ClashCoreViewer({ ref }: { ref?: Ref<DialogRef> }) {
onClick={onUpgrade}
>
{t('shared.actions.upgrade')}
</LoadingButton>
<LoadingButton
</Button>
<Button
variant="contained"
size="small"
startIcon={<RestartAltRounded />}
@ -142,7 +142,7 @@ export function ClashCoreViewer({ ref }: { ref?: Ref<DialogRef> }) {
onClick={onRestart}
>
{t('shared.actions.restart')}
</LoadingButton>
</Button>
</Box>
</Box>
}

View File

@ -20,7 +20,6 @@ import {
RefreshRounded,
TextSnippetOutlined,
} from '@mui/icons-material'
import { LoadingButton } from '@mui/lab'
import { Box, Button, Divider, Grid, IconButton, Stack } from '@mui/material'
import { useQuery } from '@tanstack/react-query'
import { listen, TauriEvent } from '@tauri-apps/api/event'
@ -970,7 +969,7 @@ const ProfilePage = () => {
},
}}
/>
<LoadingButton
<Button
disabled={!url || disabled}
loading={loading}
variant="contained"
@ -979,7 +978,7 @@ const ProfilePage = () => {
onClick={onImport}
>
{t('profiles.page.actions.import')}
</LoadingButton>
</Button>
<Button
variant="contained"
size="small"