mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: enhance backup functionality with attachment options
- Added support for including attachments in backup exports. - Updated backup-related interfaces and functions to handle attachment options. - Introduced a new UI component for selecting attachment inclusion during backup operations. - Modified existing components to integrate the new attachment functionality. - Improved user feedback and error handling during backup processes.
This commit is contained in:
@@ -92,7 +92,7 @@ export interface AppMainRoutesProps {
|
||||
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
|
||||
onDeleteUser: (userId: string) => Promise<void>;
|
||||
onRevokeInvite: (code: string) => Promise<void>;
|
||||
onExportBackup: () => Promise<void>;
|
||||
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
|
||||
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
||||
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||
|
||||
@@ -30,7 +30,7 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar
|
||||
|
||||
interface BackupCenterPageProps {
|
||||
currentUserId: string | null;
|
||||
onExport: () => Promise<void>;
|
||||
onExport: (includeAttachments?: boolean) => Promise<void>;
|
||||
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onLoadSettings: () => Promise<AdminBackupSettings>;
|
||||
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||
@@ -44,11 +44,10 @@ interface BackupCenterPageProps {
|
||||
|
||||
function buildSkippedImportMessage(result: AdminBackupImportResponse): string | null {
|
||||
const skipped = result.skipped;
|
||||
if (!skipped || (!skipped.attachments && !skipped.sendFiles)) return null;
|
||||
if (!skipped || !skipped.attachments) return null;
|
||||
return t('txt_backup_restore_skipped_summary', {
|
||||
reason: skipped.reason || t('txt_backup_restore_skipped_reason_default'),
|
||||
attachments: String(skipped.attachments),
|
||||
sendFiles: String(skipped.sendFiles),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,6 +58,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportIncludeAttachments, setExportIncludeAttachments] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [loadingSettings, setLoadingSettings] = useState(true);
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
@@ -67,6 +67,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const [downloadingRemotePath, setDownloadingRemotePath] = useState('');
|
||||
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
|
||||
const [restoringRemotePath, setRestoringRemotePath] = useState('');
|
||||
const [remoteRestoreStatusText, setRemoteRestoreStatusText] = useState('');
|
||||
const [deletingRemotePath, setDeletingRemotePath] = useState('');
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
|
||||
@@ -276,7 +277,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
setLocalError('');
|
||||
setExporting(true);
|
||||
try {
|
||||
await props.onExport();
|
||||
await props.onExport(exportIncludeAttachments);
|
||||
props.onNotify('success', t('txt_backup_export_success'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
||||
@@ -417,11 +418,13 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
async function runRemoteRestore(path: string, replaceExisting: boolean) {
|
||||
if (!savedSelectedDestination) return;
|
||||
setRestoringRemotePath(path);
|
||||
setRemoteRestoreStatusText(replaceExisting ? t('txt_backup_remote_restore_stage_replace') : t('txt_backup_remote_restore_stage_prepare'));
|
||||
setLocalError('');
|
||||
try {
|
||||
const result = await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
|
||||
setConfirmRemoteReplaceOpen(false);
|
||||
setPendingRemoteRestorePath('');
|
||||
setRemoteRestoreStatusText('');
|
||||
props.onNotify('success', t('txt_backup_restore_success_relogin'));
|
||||
const skippedMessage = buildSkippedImportMessage(result);
|
||||
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||
@@ -429,9 +432,11 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||
setPendingRemoteRestorePath(path);
|
||||
setConfirmRemoteReplaceOpen(true);
|
||||
setRemoteRestoreStatusText('');
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
|
||||
setRemoteRestoreStatusText('');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
} finally {
|
||||
@@ -459,11 +464,13 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
disableWhileBusy={disableWhileBusy}
|
||||
exporting={exporting}
|
||||
importing={importing}
|
||||
exportIncludeAttachments={exportIncludeAttachments}
|
||||
selectedProviderId={selectedProviderId}
|
||||
recommendedWebDavProviders={recommendedWebDavProviders}
|
||||
recommendedS3Providers={recommendedS3Providers}
|
||||
onExport={() => void handleExport()}
|
||||
onImport={() => fileInputRef.current?.click()}
|
||||
onExportIncludeAttachmentsChange={setExportIncludeAttachments}
|
||||
onSelectProvider={(providerId) => setSelectedProviderId(providerId)}
|
||||
/>
|
||||
|
||||
@@ -526,6 +533,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
/>
|
||||
|
||||
{localError ? <div className="local-error">{localError}</div> : null}
|
||||
{!localError && remoteRestoreStatusText ? <div className="status-ok">{remoteRestoreStatusText}</div> : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmLocalRestoreOpen}
|
||||
@@ -545,11 +553,14 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
open={confirmReplaceOpen}
|
||||
title={t('txt_backup_replace_confirm_title')}
|
||||
message={t('txt_backup_replace_confirm_message')}
|
||||
confirmText={t('txt_backup_clear_and_restore')}
|
||||
confirmText={importing ? t('txt_backup_restoring') : t('txt_backup_clear_and_restore')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={importing}
|
||||
cancelDisabled={importing}
|
||||
danger
|
||||
onConfirm={() => void runLocalRestore(true)}
|
||||
onCancel={() => {
|
||||
if (importing) return;
|
||||
setConfirmReplaceOpen(false);
|
||||
resetSelectedFile();
|
||||
}}
|
||||
@@ -559,11 +570,14 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
open={confirmRemoteReplaceOpen}
|
||||
title={t('txt_backup_replace_confirm_title')}
|
||||
message={t('txt_backup_replace_confirm_message')}
|
||||
confirmText={t('txt_backup_clear_and_restore')}
|
||||
confirmText={restoringRemotePath ? t('txt_backup_restoring') : t('txt_backup_clear_and_restore')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={!!restoringRemotePath}
|
||||
cancelDisabled={!!restoringRemotePath}
|
||||
danger
|
||||
onConfirm={() => void runRemoteRestore(pendingRemoteRestorePath, true)}
|
||||
onCancel={() => {
|
||||
if (restoringRemotePath) return;
|
||||
setConfirmRemoteReplaceOpen(false);
|
||||
setPendingRemoteRestorePath('');
|
||||
}}
|
||||
|
||||
@@ -11,6 +11,8 @@ interface ConfirmDialogProps {
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
hideCancel?: boolean;
|
||||
confirmDisabled?: boolean;
|
||||
cancelDisabled?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
children?: ComponentChildren;
|
||||
@@ -25,6 +27,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
className="dialog-card"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (props.confirmDisabled) return;
|
||||
props.onConfirm();
|
||||
}}
|
||||
>
|
||||
@@ -34,12 +37,21 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||
disabled={props.confirmDisabled}
|
||||
>
|
||||
<Check size={14} className="btn-icon" />
|
||||
{props.confirmText || t('txt_yes')}
|
||||
</button>
|
||||
{!props.hideCancel && (
|
||||
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary dialog-btn"
|
||||
disabled={props.cancelDisabled}
|
||||
onClick={() => {
|
||||
if (props.cancelDisabled) return;
|
||||
props.onCancel();
|
||||
}}
|
||||
>
|
||||
<X size={14} className="btn-icon" />
|
||||
{props.cancelText || t('txt_no')}
|
||||
</button>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { COMMON_TIME_ZONES, WEEKDAY_OPTIONS, getDestinationTypeLabel } from '@/l
|
||||
import type { RecommendedProvider } from '@/lib/backup-recommendations';
|
||||
import { RemoteBackupBrowser } from './RemoteBackupBrowser';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
|
||||
|
||||
interface BackupDestinationDetailProps {
|
||||
selectedRecommendedProvider: RecommendedProvider | null;
|
||||
@@ -287,6 +288,15 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<BackupIncludeAttachmentsField
|
||||
checked={props.selectedDestination.includeAttachments}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onChange={(checked) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
includeAttachments: checked,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{props.selectedDestination.schedule.frequency === 'weekly' ? (
|
||||
<div className="field-grid backup-detail-schedule-extra-grid">
|
||||
<label className="field">
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface BackupIncludeAttachmentsFieldProps {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
showHelp?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function BackupIncludeAttachmentsField(props: BackupIncludeAttachmentsFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!wrapRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="backup-option-field">
|
||||
<label className="backup-option-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.checked}
|
||||
disabled={props.disabled}
|
||||
onInput={(event) => props.onChange((event.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span>{t('txt_backup_include_attachments')}</span>
|
||||
</label>
|
||||
{props.showHelp !== false ? (
|
||||
<div ref={wrapRef} className={`backup-help-wrap ${open ? 'open' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="backup-help-trigger"
|
||||
aria-label={t('txt_backup_include_attachments_help_button')}
|
||||
aria-expanded={open ? 'true' : 'false'}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<div className="backup-help-bubble" role="tooltip">
|
||||
{t('txt_backup_include_attachments_help')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,19 @@ import { Download, FileUp } from 'lucide-preact';
|
||||
import type { RecommendedProvider } from '@/lib/backup-recommendations';
|
||||
import { hasLinkedStorages } from '@/lib/backup-recommendations';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
|
||||
|
||||
interface BackupOperationsSidebarProps {
|
||||
disableWhileBusy: boolean;
|
||||
exporting: boolean;
|
||||
importing: boolean;
|
||||
exportIncludeAttachments: boolean;
|
||||
selectedProviderId: string | null;
|
||||
recommendedWebDavProviders: RecommendedProvider[];
|
||||
recommendedS3Providers: RecommendedProvider[];
|
||||
onExport: () => void;
|
||||
onImport: () => void;
|
||||
onExportIncludeAttachmentsChange: (checked: boolean) => void;
|
||||
onSelectProvider: (providerId: string) => void;
|
||||
}
|
||||
|
||||
@@ -26,6 +29,12 @@ export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
|
||||
<Download size={14} className="btn-icon" />
|
||||
{props.exporting ? t('txt_backup_exporting') : t('txt_backup_export')}
|
||||
</button>
|
||||
<BackupIncludeAttachmentsField
|
||||
checked={props.exportIncludeAttachments}
|
||||
disabled={props.disableWhileBusy}
|
||||
showHelp={false}
|
||||
onChange={props.onExportIncludeAttachmentsChange}
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary" disabled={props.disableWhileBusy} onClick={props.onImport}>
|
||||
<FileUp size={14} className="btn-icon" />
|
||||
{props.importing ? t('txt_backup_restoring') : t('txt_backup_import')}
|
||||
|
||||
@@ -102,22 +102,22 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
|
||||
<FolderOpen size={14} className="btn-icon" />
|
||||
{t('txt_backup_remote_open')}
|
||||
</button>
|
||||
) : (
|
||||
) : isZipCandidate(item) ? (
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy || props.downloadingRemotePath === item.path || !isZipCandidate(item)} onClick={() => props.onDownload(item.path)}>
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy || props.downloadingRemotePath === item.path} onClick={() => props.onDownload(item.path)}>
|
||||
<Download size={14} className="btn-icon" />
|
||||
{getDownloadLabel(item.path)}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary small" disabled={props.disableWhileBusy || props.restoringRemotePath === item.path || !isZipCandidate(item)} onClick={() => props.onRestore(item.path)}>
|
||||
<button type="button" className="btn btn-primary small" disabled={props.disableWhileBusy || props.restoringRemotePath === item.path} onClick={() => props.onRestore(item.path)}>
|
||||
<RotateCcw size={14} className="btn-icon" />
|
||||
{props.restoringRemotePath === item.path ? t('txt_backup_restoring') : t('txt_backup_remote_restore')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger small" disabled={props.disableWhileBusy || props.deletingRemotePath === item.path || !isZipCandidate(item)} onClick={() => props.onPromptDelete(item.path)}>
|
||||
<button type="button" className="btn btn-danger small" disabled={props.disableWhileBusy || props.deletingRemotePath === item.path} onClick={() => props.onPromptDelete(item.path)}>
|
||||
<Trash2 size={14} className="btn-icon" />
|
||||
{props.deletingRemotePath === item.path ? t('txt_backup_remote_deleting') : t('txt_delete')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user