mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: enhance backup import functionality to handle skipped items and provide detailed feedback
This commit is contained in:
@@ -153,6 +153,9 @@ async function runImportAndAudit(
|
|||||||
ciphers: imported.result.imported.ciphers,
|
ciphers: imported.result.imported.ciphers,
|
||||||
attachments: imported.result.imported.attachmentFiles,
|
attachments: imported.result.imported.attachmentFiles,
|
||||||
sendFiles: imported.result.imported.sendFiles,
|
sendFiles: imported.result.imported.sendFiles,
|
||||||
|
skippedAttachments: imported.result.skipped.attachments,
|
||||||
|
skippedSendFiles: imported.result.skipped.sendFiles,
|
||||||
|
skippedReason: imported.result.skipped.reason,
|
||||||
replaceExisting,
|
replaceExisting,
|
||||||
...metadata,
|
...metadata,
|
||||||
});
|
});
|
||||||
|
|||||||
+101
-10
@@ -19,6 +19,16 @@ export interface BackupImportResultBody {
|
|||||||
attachmentFiles: number;
|
attachmentFiles: number;
|
||||||
sendFiles: number;
|
sendFiles: number;
|
||||||
};
|
};
|
||||||
|
skipped: {
|
||||||
|
reason: string | null;
|
||||||
|
attachments: number;
|
||||||
|
sendFiles: number;
|
||||||
|
items: Array<{
|
||||||
|
kind: 'attachment' | 'send-file';
|
||||||
|
path: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupImportExecutionResult {
|
export interface BackupImportExecutionResult {
|
||||||
@@ -106,19 +116,99 @@ function collectImportedBlobKeys(db: BackupPayload['db']): Set<string> {
|
|||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateImportBlobLimits(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): void {
|
const KV_BLOB_SKIP_REASON = 'Cloudflare KV object size limit (25 MB)';
|
||||||
if (getBlobStorageKind(env) !== 'kv') return;
|
|
||||||
|
interface BackupImportSkipSummary {
|
||||||
|
reason: string | null;
|
||||||
|
attachments: number;
|
||||||
|
sendFiles: number;
|
||||||
|
items: Array<{
|
||||||
|
kind: 'attachment' | 'send-file';
|
||||||
|
path: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreparedBackupImportPayload {
|
||||||
|
payload: BackupPayload;
|
||||||
|
skipped: BackupImportSkipSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): PreparedBackupImportPayload {
|
||||||
|
if (getBlobStorageKind(env) !== 'kv') {
|
||||||
|
return {
|
||||||
|
payload,
|
||||||
|
skipped: {
|
||||||
|
reason: null,
|
||||||
|
attachments: 0,
|
||||||
|
sendFiles: 0,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const oversizedAttachmentPaths = new Set<string>();
|
||||||
|
const oversizedSendPaths = new Set<string>();
|
||||||
|
const skippedItems: BackupImportSkipSummary['items'] = [];
|
||||||
|
|
||||||
for (const entry of Object.keys(files)) {
|
for (const entry of Object.keys(files)) {
|
||||||
if (!entry.endsWith('.bin')) continue;
|
if (!entry.endsWith('.bin')) continue;
|
||||||
if (files[entry].byteLength > KV_MAX_OBJECT_BYTES) {
|
const sizeBytes = files[entry].byteLength;
|
||||||
throw new Error(`Backup file ${entry} exceeds the Cloudflare KV object size limit`);
|
if (sizeBytes <= KV_MAX_OBJECT_BYTES) continue;
|
||||||
|
if (entry.startsWith('attachments/')) {
|
||||||
|
oversizedAttachmentPaths.add(entry);
|
||||||
|
skippedItems.push({ kind: 'attachment', path: entry, sizeBytes });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.startsWith('send-files/')) {
|
||||||
|
oversizedSendPaths.add(entry);
|
||||||
|
skippedItems.push({ kind: 'send-file', path: entry, sizeBytes });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ((payload.db.attachments || []).length > 0 || (payload.db.sends || []).length > 0) {
|
|
||||||
if (!env.ATTACHMENTS_KV) {
|
const nextAttachments = (payload.db.attachments || []).filter((row) => {
|
||||||
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
}
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) return false;
|
||||||
|
return !oversizedAttachmentPaths.has(`attachments/${cipherId}/${attachmentId}.bin`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextSends = (payload.db.sends || []).filter((row) => {
|
||||||
|
const sendId = String(row.id || '').trim();
|
||||||
|
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||||
|
if (!sendId || !fileId) return true;
|
||||||
|
return !oversizedSendPaths.has(`send-files/${sendId}/${fileId}.bin`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextPayload: BackupPayload = {
|
||||||
|
...payload,
|
||||||
|
db: {
|
||||||
|
...payload.db,
|
||||||
|
attachments: nextAttachments,
|
||||||
|
sends: nextSends,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const needsKvBlobStorage = nextAttachments.length > 0
|
||||||
|
|| nextSends.some((row) => {
|
||||||
|
const sendId = String(row.id || '').trim();
|
||||||
|
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||||
|
return !!sendId && !!fileId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (needsKvBlobStorage && !env.ATTACHMENTS_KV) {
|
||||||
|
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload: nextPayload,
|
||||||
|
skipped: {
|
||||||
|
reason: skippedItems.length ? KV_BLOB_SKIP_REASON : null,
|
||||||
|
attachments: skippedItems.filter((item) => item.kind === 'attachment').length,
|
||||||
|
sendFiles: skippedItems.filter((item) => item.kind === 'send-file').length,
|
||||||
|
items: skippedItems,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
|
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
|
||||||
@@ -211,7 +301,7 @@ export async function importBackupArchiveBytes(
|
|||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const parsed = parseBackupArchive(archiveBytes);
|
const parsed = parseBackupArchive(archiveBytes);
|
||||||
validateBackupPayloadContents(parsed.payload, parsed.files);
|
validateBackupPayloadContents(parsed.payload, parsed.files);
|
||||||
validateImportBlobLimits(env, parsed.payload, parsed.files);
|
const prepared = prepareImportPayloadForTarget(env, parsed.payload, parsed.files);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureImportTargetIsFresh(env.DB);
|
await ensureImportTargetIsFresh(env.DB);
|
||||||
@@ -222,7 +312,7 @@ export async function importBackupArchiveBytes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
||||||
const { db } = parsed.payload;
|
const { db } = prepared.payload;
|
||||||
await importBackupRows(env.DB, db);
|
await importBackupRows(env.DB, db);
|
||||||
await normalizeImportedBackupSettings(storage, env, 'UTC');
|
await normalizeImportedBackupSettings(storage, env, 'UTC');
|
||||||
|
|
||||||
@@ -248,6 +338,7 @@ export async function importBackupArchiveBytes(
|
|||||||
attachmentFiles: blobCounts.attachments,
|
attachmentFiles: blobCounts.attachments,
|
||||||
sendFiles: blobCounts.sendFiles,
|
sendFiles: blobCounts.sendFiles,
|
||||||
},
|
},
|
||||||
|
skipped: prepared.skipped,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { lazy, Suspense } from 'preact/compat';
|
|||||||
import { Link, Route, Switch } from 'wouter';
|
import { Link, Route, Switch } from 'wouter';
|
||||||
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||||
import type { AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||||
@@ -88,14 +88,14 @@ export interface AppMainRoutesProps {
|
|||||||
onDeleteUser: (userId: string) => Promise<void>;
|
onDeleteUser: (userId: string) => Promise<void>;
|
||||||
onRevokeInvite: (code: string) => Promise<void>;
|
onRevokeInvite: (code: string) => Promise<void>;
|
||||||
onExportBackup: () => Promise<void>;
|
onExportBackup: () => Promise<void>;
|
||||||
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<void>;
|
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
||||||
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<void>;
|
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import {
|
import {
|
||||||
|
type AdminBackupImportResponse,
|
||||||
type AdminBackupRunResponse,
|
type AdminBackupRunResponse,
|
||||||
type AdminBackupSettings,
|
type AdminBackupSettings,
|
||||||
type BackupDestinationRecord,
|
type BackupDestinationRecord,
|
||||||
@@ -30,15 +31,25 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar
|
|||||||
interface BackupCenterPageProps {
|
interface BackupCenterPageProps {
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
onExport: () => Promise<void>;
|
onExport: () => Promise<void>;
|
||||||
onImport: (file: File, replaceExisting?: boolean) => Promise<void>;
|
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onLoadSettings: () => Promise<AdminBackupSettings>;
|
onLoadSettings: () => Promise<AdminBackupSettings>;
|
||||||
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<void>;
|
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSkippedImportMessage(result: AdminBackupImportResponse): string | null {
|
||||||
|
const skipped = result.skipped;
|
||||||
|
if (!skipped || (!skipped.attachments && !skipped.sendFiles)) 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),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BackupCenterPage(props: BackupCenterPageProps) {
|
export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||||
@@ -286,8 +297,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
setLocalError('');
|
setLocalError('');
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
try {
|
try {
|
||||||
await props.onImport(selectedFile, replaceExisting);
|
const result = await props.onImport(selectedFile, replaceExisting);
|
||||||
props.onNotify('success', t('txt_backup_restore_success_relogin'));
|
props.onNotify('success', t('txt_backup_restore_success_relogin'));
|
||||||
|
const skippedMessage = buildSkippedImportMessage(result);
|
||||||
|
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||||
resetSelectedFile();
|
resetSelectedFile();
|
||||||
setConfirmLocalRestoreOpen(false);
|
setConfirmLocalRestoreOpen(false);
|
||||||
setConfirmReplaceOpen(false);
|
setConfirmReplaceOpen(false);
|
||||||
@@ -406,10 +419,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
setRestoringRemotePath(path);
|
setRestoringRemotePath(path);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
|
const result = await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
|
||||||
setConfirmRemoteReplaceOpen(false);
|
setConfirmRemoteReplaceOpen(false);
|
||||||
setPendingRemoteRestorePath('');
|
setPendingRemoteRestorePath('');
|
||||||
props.onNotify('success', t('txt_backup_restore_success_relogin'));
|
props.onNotify('success', t('txt_backup_restore_success_relogin'));
|
||||||
|
const skippedMessage = buildSkippedImportMessage(result);
|
||||||
|
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||||
setPendingRemoteRestorePath(path);
|
setPendingRemoteRestorePath(path);
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async importBackup(file: File, replaceExisting: boolean = false) {
|
async importBackup(file: File, replaceExisting: boolean = false) {
|
||||||
await importAdminBackup(authedFetch, file, replaceExisting);
|
const result = await importAdminBackup(authedFetch, file, replaceExisting);
|
||||||
onImported?.();
|
onImported?.();
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadSettings() {
|
async loadSettings() {
|
||||||
@@ -60,8 +61,9 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) {
|
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) {
|
||||||
await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting);
|
const result = await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting);
|
||||||
onRestored?.();
|
onRestored?.();
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[authedFetch, onImported, onRestored]
|
[authedFetch, onImported, onRestored]
|
||||||
|
|||||||
@@ -86,9 +86,23 @@ export interface AdminBackupImportCounts {
|
|||||||
sendFiles: number;
|
sendFiles: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminBackupImportSkippedItem {
|
||||||
|
kind: 'attachment' | 'send-file';
|
||||||
|
path: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminBackupImportSkipped {
|
||||||
|
reason: string | null;
|
||||||
|
attachments: number;
|
||||||
|
sendFiles: number;
|
||||||
|
items: AdminBackupImportSkippedItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminBackupImportResponse {
|
export interface AdminBackupImportResponse {
|
||||||
object: 'instance-backup-import';
|
object: 'instance-backup-import';
|
||||||
imported: AdminBackupImportCounts;
|
imported: AdminBackupImportCounts;
|
||||||
|
skipped: AdminBackupImportSkipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminBackupExportPayload {
|
export interface AdminBackupExportPayload {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_backup_export_success: "Backup exported",
|
txt_backup_export_success: "Backup exported",
|
||||||
txt_backup_import_success_relogin: "Backup restored. Please sign in again.",
|
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_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_reason_default: "Some files could not be restored",
|
||||||
txt_backup_export_failed: "Backup export failed",
|
txt_backup_export_failed: "Backup export failed",
|
||||||
txt_backup_import_failed: "Backup restore failed",
|
txt_backup_import_failed: "Backup restore failed",
|
||||||
txt_backup_restore_failed: "Backup restore failed",
|
txt_backup_restore_failed: "Backup restore failed",
|
||||||
@@ -606,6 +608,8 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_backup_export_success: '备份已导出',
|
txt_backup_export_success: '备份已导出',
|
||||||
txt_backup_import_success_relogin: '备份已还原,请重新登录',
|
txt_backup_import_success_relogin: '备份已还原,请重新登录',
|
||||||
txt_backup_restore_success_relogin: '备份已还原,请重新登录',
|
txt_backup_restore_success_relogin: '备份已还原,请重新登录',
|
||||||
|
txt_backup_restore_skipped_summary: '{reason},已跳过 {attachments} 个附件和 {sendFiles} 个 Send 文件',
|
||||||
|
txt_backup_restore_skipped_reason_default: '部分文件无法还原',
|
||||||
txt_backup_export_failed: '备份导出失败',
|
txt_backup_export_failed: '备份导出失败',
|
||||||
txt_backup_import_failed: '备份还原失败',
|
txt_backup_import_failed: '备份还原失败',
|
||||||
txt_backup_restore_failed: '备份还原失败',
|
txt_backup_restore_failed: '备份还原失败',
|
||||||
|
|||||||
Reference in New Issue
Block a user