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:
shuaiplus
2026-03-20 04:55:23 +08:00
parent 3d38424d77
commit cbf1e86881
19 changed files with 883 additions and 352 deletions
+1 -1
View File
@@ -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>;
+20 -6
View File
@@ -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('');
}}
+13 -1
View File
@@ -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>
))}
+3 -3
View File
@@ -1,8 +1,8 @@
import { useMemo } from 'preact/hooks';
import {
buildCompleteAdminBackupExport,
deleteRemoteBackup,
downloadRemoteBackup,
exportAdminBackup,
getAdminBackupSettings,
importAdminBackup,
listRemoteBackups,
@@ -24,8 +24,8 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return useMemo(
() => ({
async exportBackup() {
const payload = await exportAdminBackup(authedFetch);
async exportBackup(includeAttachments: boolean = false) {
const payload = await buildCompleteAdminBackupExport(authedFetch, includeAttachments);
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
},
+63 -6
View File
@@ -16,6 +16,7 @@ import {
type AuthedFetch,
} from './shared';
import { readResponseBytesWithProgress } from '../download';
import { unzipSync, zipSync } from 'fflate';
export type {
BackupDestinationConfig,
@@ -81,13 +82,11 @@ export interface AdminBackupImportCounts {
folders: number;
ciphers: number;
attachments: number;
sends: number;
attachmentFiles: number;
sendFiles: number;
}
export interface AdminBackupImportSkippedItem {
kind: 'attachment' | 'send-file';
kind: 'attachment';
path: string;
sizeBytes: number;
}
@@ -95,7 +94,6 @@ export interface AdminBackupImportSkippedItem {
export interface AdminBackupImportSkipped {
reason: string | null;
attachments: number;
sendFiles: number;
items: AdminBackupImportSkippedItem[];
}
@@ -111,8 +109,25 @@ export interface AdminBackupExportPayload {
bytes: Uint8Array;
}
export async function exportAdminBackup(authedFetch: AuthedFetch): Promise<AdminBackupExportPayload> {
const resp = await authedFetch('/api/admin/backup/export', { method: 'POST' });
interface BackupExportManifestAttachmentBlob {
cipherId: string;
attachmentId: string;
blobName: string;
}
interface BackupExportManifest {
attachmentBlobs?: BackupExportManifestAttachmentBlob[];
}
export async function exportAdminBackup(
authedFetch: AuthedFetch,
includeAttachments: boolean = false
): Promise<AdminBackupExportPayload> {
const resp = await authedFetch('/api/admin/backup/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ includeAttachments }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip';
@@ -121,6 +136,48 @@ export async function exportAdminBackup(authedFetch: AuthedFetch): Promise<Admin
return { fileName, mimeType, bytes };
}
export async function downloadAdminBackupAttachmentBlob(
authedFetch: AuthedFetch,
blobName: string
): Promise<Uint8Array> {
const params = new URLSearchParams();
params.set('blobName', blobName);
const resp = await authedFetch(`/api/admin/backup/blob?${params.toString()}`, { method: 'GET' });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
return new Uint8Array(await resp.arrayBuffer());
}
export async function buildCompleteAdminBackupExport(
authedFetch: AuthedFetch,
includeAttachments: boolean = false
): Promise<AdminBackupExportPayload> {
const payload = await exportAdminBackup(authedFetch, includeAttachments);
if (!includeAttachments) return payload;
const zipped = unzipSync(payload.bytes);
const manifestBytes = zipped['manifest.json'];
if (!manifestBytes) {
throw new Error(t('txt_backup_export_failed'));
}
let manifest: BackupExportManifest;
try {
manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as BackupExportManifest;
} catch {
throw new Error(t('txt_backup_export_failed'));
}
for (const attachment of manifest.attachmentBlobs || []) {
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName);
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
}
return {
...payload,
bytes: zipSync(zipped, { level: 0 }),
};
}
export async function getAdminBackupSettings(authedFetch: AuthedFetch): Promise<AdminBackupSettings> {
const resp = await authedFetch('/api/admin/backup/settings', { method: 'GET' });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_load_failed')));
+4 -1
View File
@@ -100,9 +100,12 @@ function getRemoteItemSortTime(item: RemoteBackupItem): number {
}
export function compareRemoteItems(a: RemoteBackupItem, b: RemoteBackupItem): number {
const aIsAttachmentsDir = a.isDirectory && a.name === 'attachments';
const bIsAttachmentsDir = b.isDirectory && b.name === 'attachments';
if (aIsAttachmentsDir !== bIsAttachmentsDir) return aIsAttachmentsDir ? -1 : 1;
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
const timeDiff = getRemoteItemSortTime(b) - getRemoteItemSortTime(a);
if (timeDiff !== 0) return timeDiff;
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return b.name.localeCompare(a.name, 'en');
}
+14 -2
View File
@@ -17,6 +17,7 @@ const messages: Record<Locale, Record<string, string>> = {
import_export_under_construction: "Under construction.",
txt_backup_export: "Export Backup",
txt_backup_import: "Restore",
txt_backup_include_attachments: "Include attachments",
txt_backup_export_description: "Download a full instance backup ZIP for manual safekeeping.",
txt_backup_import_description: "Upload a previously exported backup ZIP and restore it into this instance.",
txt_backup_exporting: "Exporting...",
@@ -25,7 +26,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_export_success: "Backup exported",
txt_backup_import_success_relogin: "Backup restored. Please sign in again.",
txt_backup_restore_success_relogin: "Backup restored. Please sign in again.",
txt_backup_restore_skipped_summary: "{reason}. Skipped {attachments} attachment(s) and {sendFiles} Send file(s).",
txt_backup_restore_skipped_summary: "{reason}. Skipped {attachments} attachment(s).",
txt_backup_restore_skipped_reason_default: "Some files could not be restored",
txt_backup_export_failed: "Backup export failed",
txt_backup_import_failed: "Backup restore failed",
@@ -109,6 +110,8 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_remote_download: "Download",
txt_backup_remote_downloading: "Downloading...",
txt_backup_remote_restore: "Restore",
txt_backup_remote_restore_stage_prepare: "Preparing remote backup restore...",
txt_backup_remote_restore_stage_replace: "Clearing current data and restoring remote backup...",
txt_backup_remote_loading: "Loading remote backups...",
txt_backup_remote_cached_empty: "Click Refresh to load this destination.",
txt_backup_remote_empty: "No backup files found in this folder.",
@@ -154,6 +157,9 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_retention_count: "Keep",
txt_backup_retention_count_suffix: "items",
txt_backup_retention_count_hint: "Leave empty to keep all backup files. New destinations default to 30.",
txt_backup_destination_include_attachments: "Include attachments",
txt_backup_include_attachments_help_button: "Attachment backup help",
txt_backup_include_attachments_help: "Attachments are stored incrementally in the remote attachments folder, so later backups usually only upload new files. Deleting an attachment locally does not remove earlier remote copies. During restore, NodeWarden reads the required files from the attachments folder and skips any attachment that is no longer available.",
txt_backup_enable_schedule: "Enable automatic daily backup",
txt_backup_schedule_note: "The worker checks the schedule every 5 minutes and runs the backup as soon as the selected time window is reached.",
txt_backup_schedule_disabled: "Disabled",
@@ -629,6 +635,7 @@ const zhCNOverrides: Record<string, string> = {
import_export_under_construction: '正在搭建中',
txt_backup_export: '导出备份',
txt_backup_import: '还原',
txt_backup_include_attachments: '包含附件',
txt_backup_export_description: '下载一个完整的实例备份 ZIP,手动保管即可。',
txt_backup_import_description: '上传之前导出的备份 ZIP,并还原到当前实例。',
txt_backup_exporting: '正在导出...',
@@ -637,7 +644,7 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_export_success: '备份已导出',
txt_backup_import_success_relogin: '备份已还原,请重新登录',
txt_backup_restore_success_relogin: '备份已还原,请重新登录',
txt_backup_restore_skipped_summary: '{reason},已跳过 {attachments} 个附件和 {sendFiles} 个 Send 文件',
txt_backup_restore_skipped_summary: '{reason},已跳过 {attachments} 个附件',
txt_backup_restore_skipped_reason_default: '部分文件无法还原',
txt_backup_export_failed: '备份导出失败',
txt_backup_import_failed: '备份还原失败',
@@ -721,6 +728,8 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_remote_download: '下载',
txt_backup_remote_downloading: '下载中...',
txt_backup_remote_restore: '还原',
txt_backup_remote_restore_stage_prepare: '正在读取远端备份并检查可恢复内容...',
txt_backup_remote_restore_stage_replace: '正在清空当前数据并还原远端备份,请稍候...',
txt_backup_remote_loading: '正在读取远端备份...',
txt_backup_remote_cached_empty: '点击“刷新”后读取',
txt_backup_remote_empty: '这个目录下还没有备份文件',
@@ -766,6 +775,9 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_retention_count: '只保留',
txt_backup_retention_count_suffix: '个',
txt_backup_retention_count_hint: '留空表示不限,新建备份地点默认保留 30 个',
txt_backup_destination_include_attachments: '包含附件',
txt_backup_include_attachments_help_button: '附件备份说明',
txt_backup_include_attachments_help: '附件会以增量方式保存在远端的 attachments 文件夹中,后续备份通常只上传新增文件。你在本地删除附件时,已经备份到远端的旧文件不会自动删除。恢复时会按需从 attachments 文件夹读取对应附件,找不到的附件会自动跳过。',
txt_backup_enable_schedule: '启用每日自动备份',
txt_backup_schedule_note: 'Worker 每 5 分钟检查一次计划,到达你设定的时间窗口后会尽快执行备份。',
txt_backup_schedule_disabled: '未启用',
+119 -1
View File
@@ -1434,6 +1434,105 @@ input[type='file'].input::file-selector-button:hover {
gap: 10px;
}
.backup-option-field {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.backup-option-label {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 15px;
font-weight: 700;
color: #0f172a;
cursor: pointer;
}
.backup-option-label input[type='checkbox'] {
width: 22px;
height: 22px;
margin: 0;
flex-shrink: 0;
}
.backup-help-wrap {
position: relative;
display: inline-flex;
align-items: center;
}
.backup-help-trigger {
width: 22px;
height: 22px;
border: 1px solid #bfd1f3;
border-radius: 999px;
padding: 0;
background: #eef4ff;
color: #1d4ed8;
font-size: 13px;
font-weight: 800;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.backup-help-trigger:hover,
.backup-help-trigger:focus-visible {
border-color: #7ea4ef;
background: #e1ecff;
outline: none;
}
.backup-help-bubble {
position: absolute;
left: 50%;
top: calc(100% + 10px);
z-index: 30;
width: min(320px, calc(100vw - 40px));
padding: 10px 12px;
border: 1px solid #d5dce7;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.14);
color: #475467;
font-size: 13px;
line-height: 1.55;
transform: translate(-50%, -4px);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease;
}
.backup-help-bubble::before {
content: '';
position: absolute;
left: 50%;
top: -6px;
width: 10px;
height: 10px;
background: #ffffff;
border-left: 1px solid #d5dce7;
border-top: 1px solid #d5dce7;
transform: translateX(-50%) rotate(45deg);
}
.backup-help-wrap:hover .backup-help-bubble,
.backup-help-wrap:focus-within .backup-help-bubble,
.backup-help-wrap.open .backup-help-bubble {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translate(-50%, 0);
}
.backup-manual-inline-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1646,7 +1745,6 @@ input[type='file'].input::file-selector-button:hover {
.backup-detail-schedule-grid {
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
margin-bottom: 12px;
}
.backup-retention-input {
@@ -3051,4 +3149,24 @@ input[type='file'].input::file-selector-button:hover {
.backup-name-row {
grid-template-columns: 1fr;
}
.backup-option-field {
align-items: flex-start;
}
.backup-help-bubble {
left: 0;
transform: translate(0, -4px);
}
.backup-help-bubble::before {
left: 16px;
transform: rotate(45deg);
}
.backup-help-wrap:hover .backup-help-bubble,
.backup-help-wrap:focus-within .backup-help-bubble,
.backup-help-wrap.open .backup-help-bubble {
transform: translate(0, 0);
}
}