-
-
-
-
-
- {{ $t('dataManager.backupWorkspace.exportTitle') }}
-
- {{ $t('dataManager.export.description') }}
-
-
-
-
- {{ $t('dataManager.export.scopeTitle') }}
+
+
-
+
{{ isExporting ? $t('common.exporting') : $t('dataManager.export.button') }}
-
+
-
+
+ {{ $t('dataManager.backupWorkspace.exportTitle') }}
-
+ {{ $t('dataManager.export.description') }}
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
- {{ $t('dataManager.backupWorkspace.importTitle') }}
-
- {{ $t('dataManager.import.description') }}
-
-
+
+
+
+
+
+ {{ $t('dataManager.backupWorkspace.importTitle') }}
+
+
+ {{ $t('dataManager.import.description') }}
+
-
-
+
@@ -297,10 +707,11 @@ import { ref, computed, inject, onMounted, onUnmounted, watch, type Ref } from '
import { useI18n } from 'vue-i18n'
import {
NModal, NText, NButton, NUpload, NUploadDragger,
- NIcon, NAlert, NCheckbox, NRadio, NRadioGroup, type UploadFileInfo,
+ NIcon, NCheckbox, NRadio, NRadioGroup, NSelect, NInput, NProgress, NTag,
+ type UploadFileInfo,
} from 'naive-ui'
import { isRunningInElectron } from '@prompt-optimizer/core'
-import { Download, Folder, Refresh, Upload } from '@vicons/tabler'
+import { ChevronDown, Clipboard, Download, ExternalLink, Folder, Refresh, Trash, Upload } from '@vicons/tabler'
import { useToast } from '../composables/ui/useToast'
import type { AppServices } from '../types/services'
import {
@@ -321,6 +732,35 @@ import {
type StorageBreakdownItemKey,
type StorageBreakdownSummary,
} from '../utils/data-manager-storage'
+import {
+ CLOUDFLARE_R2_DEFAULT_BACKUP_PREFIX,
+ createCloudflareR2CorsConfig,
+ createRemoteObjectStore,
+ createRemoteBackupFileName,
+ GOOGLE_DRIVE_DEFAULT_BACKUP_FOLDER_NAME,
+ getCloudflareR2DashboardLinks,
+ getCloudflareR2Endpoint,
+ getSupportedRemoteBackupProviders,
+ isGoogleDriveRemoteBackupAuthorized,
+ loadRemoteBackupSettings,
+ rememberRemoteBackupProvider,
+ saveRemoteBackupSettings,
+ switchRemoteBackupProvider,
+ type RemoteBackupProviderKind,
+ type RemoteBackupProviderConfig,
+ type RemoteBackupRuntime,
+ type RemoteBackupSettings,
+} from '../utils/remote-backup'
+import {
+ analyzeRemoteSnapshotAssetCleanup,
+ cleanupRemoteSnapshotAssets,
+ createRemoteSnapshotBackup,
+ listRemoteSnapshotBackups,
+ restoreRemoteSnapshotBackup,
+ type RemoteSnapshotProgressEvent,
+ type RemoteSnapshotEntry,
+} from '../utils/remote-snapshot-backup'
+import { recordDataBackupCompleted } from '../utils/data-backup-reminder'
interface Props {
show: boolean
@@ -338,11 +778,13 @@ const emit = defineEmits()
const { t } = useI18n()
const toast = useToast()
const isDesktopRuntime = isRunningInElectron()
+const remoteRuntime: RemoteBackupRuntime = isDesktopRuntime ? 'desktop' : 'web'
const DATA_MANAGER_MODAL_MAX_WIDTH = '1200px'
const dataManagerModalStyle = {
- width: '90vw',
+ width: 'calc(100vw - 32px)',
maxWidth: DATA_MANAGER_MODAL_MAX_WIDTH,
maxHeight: '90vh',
+ overflow: 'hidden',
}
const services = inject>('services')
@@ -380,6 +822,59 @@ const favoriteMergeStrategy = ref('overwrite'
const storageSummary = ref(null)
const isRefreshingStorage = ref(false)
+const remoteSettings = ref(loadRemoteBackupSettings(remoteRuntime))
+type CloudflareR2SetupStep = 'account' | 'bucket' | 'credentials'
+type RemoteConnectionStatus = 'unconfigured' | 'unverified' | 'checking' | 'connected' | 'failed'
+
+type RemoteConnectionState = {
+ status: RemoteConnectionStatus
+ lastCheckedAt: number | null
+ message: string
+}
+
+const createRemoteConnectionState = (): RemoteConnectionState => ({
+ status: 'unconfigured',
+ lastCheckedAt: null,
+ message: '',
+})
+
+const isCloudflareR2ProviderConfigured = (
+ provider: RemoteBackupProviderConfig,
+): boolean =>
+ provider.kind === 'cloudflare-r2' &&
+ Boolean(
+ provider.accountId.trim() &&
+ provider.bucket.trim() &&
+ provider.accessKeyId.trim() &&
+ provider.secretAccessKey.trim(),
+ )
+const isCloudflareR2SetupGuideExpanded = ref(
+ remoteSettings.value.provider.kind === 'cloudflare-r2'
+ ? false
+ : false,
+)
+const cloudflareR2ExpandedSteps = ref(new Set())
+const isAuthorizingRemote = ref(false)
+const isGoogleDriveAuthorized = ref(isGoogleDriveRemoteBackupAuthorized())
+const isConnectingRemote = ref(false)
+const isRemoteConfigExpanded = ref(false)
+const remoteConnectionStates = ref>({
+ 'google-drive': createRemoteConnectionState(),
+ 'cloudflare-r2': createRemoteConnectionState(),
+ 's3-compatible': createRemoteConnectionState(),
+ webdav: createRemoteConnectionState(),
+})
+const isUploadingRemote = ref(false)
+const isLoadingRemoteList = ref(false)
+const isRestoringRemote = ref(false)
+const isCleaningRemoteAssets = ref(false)
+const remoteBackups = ref([])
+const selectedRemoteBackupId = ref(null)
+const remoteProgress = ref({
+ active: false,
+ percent: 0,
+ message: '',
+})
const storageLabelKeys: Record = {
appMainData: 'dataManager.storage.appMainData',
@@ -390,6 +885,184 @@ const storageLabelKeys: Record = {
const storageCardItemKeys: StorageBreakdownItemKey[] = ['appMainData', 'imageCache', 'favoriteImages']
+const providerLabelKeys: Record = {
+ 'google-drive': 'dataManager.remote.providers.googleDrive',
+ 'cloudflare-r2': 'dataManager.remote.providers.cloudflareR2',
+ 's3-compatible': 'dataManager.remote.providers.s3Compatible',
+ webdav: 'dataManager.remote.providers.webdav',
+}
+
+const remoteProviderOptions = computed(() =>
+ getSupportedRemoteBackupProviders(remoteRuntime).map((provider) => ({
+ label: t(providerLabelKeys[provider]),
+ value: provider,
+ }))
+)
+
+const remoteRecommendationKey = computed(() =>
+ isDesktopRuntime
+ ? 'dataManager.remote.desktopRecommendation'
+ : 'dataManager.remote.webRecommendation'
+)
+
+const remoteBackupOptions = computed(() =>
+ remoteBackups.value.map((entry) => ({
+ label: `${entry.name}${entry.updatedAt ? ` · ${new Date(entry.updatedAt).toLocaleString()}` : ''}`,
+ value: entry.id,
+ }))
+)
+
+const googleDriveAuthorizationLabel = computed(() =>
+ isGoogleDriveAuthorized.value
+ ? t('dataManager.remote.authorizedGoogleDrive')
+ : t('dataManager.remote.authorizeGoogleDrive')
+)
+
+const selectedRemoteBackup = computed(() =>
+ remoteBackups.value.find((entry) => entry.id === selectedRemoteBackupId.value) ?? null
+)
+
+const cloudflareR2Provider = computed(() =>
+ remoteSettings.value.provider.kind === 'cloudflare-r2'
+ ? remoteSettings.value.provider
+ : null
+)
+
+const cloudflareR2Links = computed(() =>
+ getCloudflareR2DashboardLinks(cloudflareR2Provider.value?.accountId ?? '')
+)
+
+const cloudflareR2EndpointPreview = computed(() =>
+ getCloudflareR2Endpoint(cloudflareR2Provider.value?.accountId ?? '')
+)
+
+const cloudflareR2BucketPreview = computed(() =>
+ cloudflareR2Provider.value?.bucket.trim() || 'prompt-optimizer-backups'
+)
+
+const isCloudflareR2Configured = computed(() =>
+ remoteSettings.value.provider.kind === 'cloudflare-r2' &&
+ isCloudflareR2ProviderConfigured(remoteSettings.value.provider)
+)
+
+const isRemoteProviderConfigured = (provider: RemoteBackupProviderConfig): boolean => {
+ if (provider.kind === 'google-drive') return isGoogleDriveAuthorized.value
+ if (provider.kind === 'cloudflare-r2') return isCloudflareR2ProviderConfigured(provider)
+ if (provider.kind === 's3-compatible') {
+ return Boolean(
+ provider.endpoint.trim() &&
+ provider.bucket.trim() &&
+ provider.accessKeyId.trim() &&
+ provider.secretAccessKey.trim(),
+ )
+ }
+ return Boolean(provider.endpoint.trim() && provider.directory.trim())
+}
+
+if (
+ remoteSettings.value.provider.kind !== 'google-drive' &&
+ isRemoteProviderConfigured(remoteSettings.value.provider)
+) {
+ remoteConnectionStates.value[remoteSettings.value.provider.kind].status = 'unverified'
+}
+
+const currentRemoteConnection = computed(() =>
+ remoteConnectionStates.value[remoteSettings.value.provider.kind]
+)
+
+const isCurrentRemoteProviderConfigured = computed(() =>
+ isRemoteProviderConfigured(remoteSettings.value.provider)
+)
+
+const isCurrentRemoteProviderConnected = computed(() =>
+ remoteSettings.value.provider.kind !== 'google-drive' &&
+ currentRemoteConnection.value.status === 'connected'
+)
+
+const showRemoteConfigForm = computed(() =>
+ remoteSettings.value.provider.kind !== 'google-drive' &&
+ (
+ !isCurrentRemoteProviderConfigured.value ||
+ isRemoteConfigExpanded.value
+ )
+)
+
+const currentRemoteConnectionLabel = computed(() =>
+ t(`dataManager.remote.connectionStatus.${currentRemoteConnection.value.status}`)
+)
+
+const currentRemoteConnectionTitle = computed(() =>
+ isCurrentRemoteProviderConnected.value
+ ? t('dataManager.remote.connectionReadyTitle')
+ : t('dataManager.remote.connectionSetupTitle')
+)
+
+const currentRemoteConnectionMessage = computed(() => {
+ const state = currentRemoteConnection.value
+ if (state.message) return state.message
+ if (!isCurrentRemoteProviderConfigured.value) return t('dataManager.remote.connectionMissingConfig')
+ if (state.status === 'connected') return remoteConnectionSummary.value
+ if (state.status === 'checking') return t('dataManager.remote.connectionChecking')
+ return t('dataManager.remote.connectionNeedsCheck')
+})
+
+const currentRemoteConnectionTagType = computed<'default' | 'success' | 'warning' | 'error' | 'info'>(() => {
+ const status = currentRemoteConnection.value.status
+ if (status === 'connected') return 'success'
+ if (status === 'failed') return 'error'
+ if (status === 'checking') return 'info'
+ return isCurrentRemoteProviderConfigured.value ? 'warning' : 'default'
+})
+
+const remoteConnectionSummary = computed(() => {
+ const provider = remoteSettings.value.provider
+ if (provider.kind === 'cloudflare-r2') {
+ return t('dataManager.remote.connectionSummary.r2', {
+ bucket: provider.bucket || cloudflareR2BucketPreview.value,
+ path: `/${CLOUDFLARE_R2_DEFAULT_BACKUP_PREFIX}`,
+ })
+ }
+ if (provider.kind === 's3-compatible') {
+ return t('dataManager.remote.connectionSummary.s3', {
+ bucket: provider.bucket || '-',
+ path: provider.prefix || 'prompt-optimizer-backups/',
+ })
+ }
+ if (provider.kind === 'webdav') {
+ return t('dataManager.remote.connectionSummary.webdav', {
+ path: provider.directory || 'prompt-optimizer-backups',
+ })
+ }
+ return ''
+})
+
+const cloudflareR2CurrentSetupStep = computed(() => {
+ const provider = cloudflareR2Provider.value
+ if (!provider) return null
+ if (!provider.accountId.trim()) return 'account'
+ if (!provider.bucket.trim()) return 'bucket'
+ if (!provider.accessKeyId.trim() || !provider.secretAccessKey.trim()) return 'credentials'
+ return null
+})
+
+const shouldShowCloudflareR2SetupGuide = computed(() =>
+ remoteSettings.value.provider.kind === 'cloudflare-r2' &&
+ (!isCloudflareR2Configured.value || isCloudflareR2SetupGuideExpanded.value)
+)
+
+const isCloudflareR2StepExpanded = (step: CloudflareR2SetupStep): boolean =>
+ cloudflareR2ExpandedSteps.value.has(step)
+
+const toggleCloudflareR2Step = (step: CloudflareR2SetupStep) => {
+ const next = new Set(cloudflareR2ExpandedSteps.value)
+ if (next.has(step)) {
+ next.delete(step)
+ } else {
+ next.add(step)
+ }
+ cloudflareR2ExpandedSteps.value = next
+}
+
const canImportFavoritesBundle = computed(() =>
availableImportSections.value.has('favorites')
)
@@ -450,6 +1123,437 @@ const refreshStorageSummary = async () => {
}
}
+const persistRemoteSettings = (markDirty = true) => {
+ remoteSettings.value = rememberRemoteBackupProvider(remoteSettings.value)
+ saveRemoteBackupSettings(remoteSettings.value)
+ const provider = remoteSettings.value.provider
+ if (markDirty && provider.kind !== 'google-drive' && currentRemoteConnection.value.status !== 'checking') {
+ remoteConnectionStates.value = {
+ ...remoteConnectionStates.value,
+ [provider.kind]: {
+ ...remoteConnectionStates.value[provider.kind],
+ status: isRemoteProviderConfigured(provider) ? 'unverified' : 'unconfigured',
+ message: '',
+ },
+ }
+ isRemoteConfigExpanded.value = true
+ }
+}
+
+const markRemoteSettingsDirty = () => persistRemoteSettings()
+
+const syncGoogleDriveAuthorizationState = () => {
+ if (isDesktopRuntime && remoteSettings.value.provider.kind === 'google-drive') return
+ isGoogleDriveAuthorized.value = isGoogleDriveRemoteBackupAuthorized()
+}
+
+const markGoogleDriveAuthorizationSuccess = () => {
+ if (isDesktopRuntime && remoteSettings.value.provider.kind === 'google-drive') {
+ isGoogleDriveAuthorized.value = true
+ return
+ }
+ syncGoogleDriveAuthorizationState()
+}
+
+const handleRemoteProviderChange = (value: string) => {
+ const kind = value as RemoteBackupProviderKind
+ remoteSettings.value = switchRemoteBackupProvider(remoteSettings.value, kind)
+ isCloudflareR2SetupGuideExpanded.value = false
+ cloudflareR2ExpandedSteps.value = new Set()
+ isRemoteConfigExpanded.value = false
+ remoteBackups.value = []
+ selectedRemoteBackupId.value = null
+ isGoogleDriveAuthorized.value = isDesktopRuntime && kind === 'google-drive'
+ ? false
+ : isGoogleDriveRemoteBackupAuthorized()
+ persistRemoteSettings()
+}
+
+const getRemoteStore = () => createRemoteObjectStore(remoteSettings.value.provider, remoteRuntime)
+
+const phaseBasePercent: Record = {
+ prepare: 5,
+ scan: 12,
+ 'asset-check': 20,
+ 'asset-upload': 28,
+ 'metadata-upload': 82,
+ 'manifest-upload': 92,
+ list: 20,
+ 'restore-validate': 15,
+ 'restore-write': 78,
+ 'cleanup-analyze': 20,
+ 'cleanup-delete': 45,
+ done: 100,
+}
+
+const phaseSpanPercent: Partial> = {
+ scan: 8,
+ 'asset-check': 45,
+ 'asset-upload': 45,
+ 'metadata-upload': 8,
+ 'restore-validate': 55,
+ 'restore-write': 18,
+ 'cleanup-delete': 45,
+}
+
+const progressPercentFor = (event: RemoteSnapshotProgressEvent): number => {
+ if (event.phase === 'done') return 100
+ const base = phaseBasePercent[event.phase] ?? 5
+ const span = phaseSpanPercent[event.phase] ?? 0
+ if (!event.total || event.total <= 0 || !event.current) return base
+ return Math.min(99, Math.max(0, Math.round(base + span * (event.current / event.total))))
+}
+
+const buildRemoteProgressMessage = (
+ operation: 'backup' | 'list' | 'restore' | 'cleanup',
+ event: RemoteSnapshotProgressEvent,
+): string => {
+ const count = event.current && event.total ? `${event.current}/${event.total}` : ''
+ const params = {
+ count,
+ item: event.item || '',
+ uploaded: event.uploaded ?? 0,
+ skipped: event.skipped ?? 0,
+ deleted: event.deleted ?? 0,
+ }
+
+ const key = `dataManager.remote.progress.${operation}.${event.phase}`
+ const message = t(key, params)
+ return message === key ? t('dataManager.remote.progress.working') : message
+}
+
+const startRemoteProgress = (
+ operation: 'backup' | 'list' | 'restore' | 'cleanup',
+ messageKey: string,
+) => {
+ remoteProgress.value = {
+ active: true,
+ percent: 3,
+ message: t(messageKey),
+ }
+}
+
+const updateRemoteProgress = (
+ operation: 'backup' | 'list' | 'restore' | 'cleanup',
+ event: RemoteSnapshotProgressEvent,
+) => {
+ remoteProgress.value = {
+ active: true,
+ percent: progressPercentFor(event),
+ message: buildRemoteProgressMessage(operation, event),
+ }
+}
+
+const finishRemoteProgress = (messageKey?: string) => {
+ remoteProgress.value = {
+ active: Boolean(messageKey),
+ percent: 100,
+ message: messageKey ? t(messageKey) : '',
+ }
+}
+
+const openExternalUrl = async (url: string) => {
+ if (typeof window !== 'undefined' && window.electronAPI?.shell) {
+ try {
+ await window.electronAPI.shell.openExternal(url)
+ return
+ } catch (error) {
+ console.error('[DataManager] Failed to open external URL in Electron:', error)
+ }
+ }
+
+ window.open(url, '_blank', 'noopener,noreferrer')
+}
+
+const openCloudflareR2Link = (
+ key: 'dashboard' | 'buckets' | 'apiTokens' | 'docs',
+) => {
+ void openExternalUrl(cloudflareR2Links.value[key])
+}
+
+const writeClipboardText = async (text: string): Promise => {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text)
+ return
+ }
+
+ const textarea = document.createElement('textarea')
+ textarea.value = text
+ textarea.style.position = 'fixed'
+ textarea.style.left = '-9999px'
+ document.body.appendChild(textarea)
+ textarea.focus()
+ textarea.select()
+ try {
+ document.execCommand('copy')
+ } finally {
+ document.body.removeChild(textarea)
+ }
+}
+
+const copyCloudflareR2CorsConfig = async () => {
+ try {
+ const origin = typeof window !== 'undefined' ? window.location.origin : ''
+ await writeClipboardText(createCloudflareR2CorsConfig(origin))
+ toast.success(t('dataManager.remote.copyCorsSuccess'))
+ } catch (error) {
+ console.error('[DataManager] Failed to copy Cloudflare R2 CORS config:', error)
+ toast.error(t('dataManager.remote.copyCorsFailed'))
+ }
+}
+
+const handleRemoteAuthorize = async () => {
+ try {
+ persistRemoteSettings(false)
+ isAuthorizingRemote.value = true
+ const objectStore = getRemoteStore()
+ if (!objectStore.authorize) {
+ toast.warning(t('dataManager.remote.authorizeUnavailable'))
+ return
+ }
+ await objectStore.authorize()
+ markGoogleDriveAuthorizationSuccess()
+ toast.success(t('dataManager.remote.authorizeSuccess'))
+ } catch (error) {
+ console.error('[DataManager] Remote authorization failed:', error)
+ toast.error(t('dataManager.remote.authorizeFailed', { message: (error as Error).message }))
+ } finally {
+ isAuthorizingRemote.value = false
+ }
+}
+
+const setRemoteConnectionState = (
+ provider: RemoteBackupProviderKind,
+ state: Partial,
+) => {
+ remoteConnectionStates.value = {
+ ...remoteConnectionStates.value,
+ [provider]: {
+ ...remoteConnectionStates.value[provider],
+ ...state,
+ },
+ }
+}
+
+const handleRemoteSaveAndConnect = async () => {
+ const provider = remoteSettings.value.provider
+ if (provider.kind === 'google-drive') return
+
+ persistRemoteSettings()
+ if (!isRemoteProviderConfigured(provider)) {
+ setRemoteConnectionState(provider.kind, {
+ status: 'unconfigured',
+ message: t('dataManager.remote.connectionMissingConfig'),
+ })
+ toast.warning(t('dataManager.remote.connectionMissingConfig'))
+ return
+ }
+
+ try {
+ isConnectingRemote.value = true
+ setRemoteConnectionState(provider.kind, {
+ status: 'checking',
+ message: t('dataManager.remote.connectionChecking'),
+ })
+ const result = await getRemoteStore().detect()
+ if (!result.ok) {
+ const failedStep = result.steps.find((step) => !step.ok)
+ throw new Error(failedStep?.message || t('dataManager.remote.connectionFailedGeneric'))
+ }
+ setRemoteConnectionState(provider.kind, {
+ status: 'connected',
+ lastCheckedAt: Date.now(),
+ message: '',
+ })
+ isRemoteConfigExpanded.value = false
+ toast.success(t('dataManager.remote.connectionSuccess'))
+ await refreshRemoteBackups({ skipConnectionCheck: true })
+ } catch (error) {
+ console.error('[DataManager] Remote connection failed:', error)
+ setRemoteConnectionState(provider.kind, {
+ status: 'failed',
+ message: (error as Error).message,
+ })
+ isRemoteConfigExpanded.value = true
+ toast.error(t('dataManager.remote.connectionFailed', { message: (error as Error).message }))
+ } finally {
+ isConnectingRemote.value = false
+ }
+}
+
+const ensureRemoteConnectionReady = (): boolean => {
+ const provider = remoteSettings.value.provider
+ if (provider.kind === 'google-drive') return true
+ if (currentRemoteConnection.value.status === 'connected') return true
+
+ toast.warning(
+ isRemoteProviderConfigured(provider)
+ ? t('dataManager.remote.connectFirst')
+ : t('dataManager.remote.connectionMissingConfig'),
+ )
+ isRemoteConfigExpanded.value = true
+ return false
+}
+
+const refreshRemoteBackups = async (options?: { skipConnectionCheck?: boolean }) => {
+ if (!options?.skipConnectionCheck && !ensureRemoteConnectionReady()) return
+
+ try {
+ persistRemoteSettings(false)
+ isLoadingRemoteList.value = true
+ startRemoteProgress('list', 'dataManager.remote.progress.list.start')
+ remoteBackups.value = await listRemoteSnapshotBackups(getRemoteStore(), (event) =>
+ updateRemoteProgress('list', event),
+ )
+ markGoogleDriveAuthorizationSuccess()
+ if (!remoteBackups.value.some((entry) => entry.id === selectedRemoteBackupId.value)) {
+ selectedRemoteBackupId.value = remoteBackups.value[0]?.id ?? null
+ }
+ } catch (error) {
+ console.error('[DataManager] Failed to list remote backups:', error)
+ toast.error(t('dataManager.remote.listFailed', { message: (error as Error).message }))
+ } finally {
+ isLoadingRemoteList.value = false
+ finishRemoteProgress()
+ }
+}
+
+const handleRemoteRefresh = () => refreshRemoteBackups()
+
+const handleRemoteUpload = async () => {
+ if (!ensureRemoteConnectionReady()) return
+
+ try {
+ persistRemoteSettings(false)
+ isUploadingRemote.value = true
+ startRemoteProgress('backup', 'dataManager.remote.progress.backup.start')
+ const servicesValue = services.value
+ if (!servicesValue) {
+ throw new Error('[DataManager] Services are not initialized. Make sure the application has started correctly.')
+ }
+ const result = await createRemoteSnapshotBackup({
+ objectStore: getRemoteStore(),
+ dataManager: getDataManager.value,
+ favoriteManager: servicesValue.favoriteManager,
+ imageStorageService: servicesValue.imageStorageService,
+ favoriteImageStorageService: servicesValue.favoriteImageStorageService,
+ sections: toExportSectionSelection(),
+ onProgress: (event) => updateRemoteProgress('backup', event),
+ })
+ const entry = result.entry
+ remoteBackups.value = [entry, ...remoteBackups.value.filter((item) => item.id !== entry.id)]
+ markGoogleDriveAuthorizationSuccess()
+ selectedRemoteBackupId.value = entry.id
+ recordDataBackupCompleted()
+ if (result.missingAssets.length > 0) {
+ toast.warning(t('dataManager.export.partialSuccess', {
+ count: result.missingAssets.length,
+ }))
+ } else {
+ toast.success(t('dataManager.remote.backupSuccess', {
+ uploaded: result.uploadedAssets,
+ skipped: result.skippedAssets,
+ }))
+ }
+ } catch (error) {
+ console.error('[DataManager] Remote backup failed:', error)
+ toast.error(t('dataManager.remote.backupFailed', { message: (error as Error).message }))
+ } finally {
+ isUploadingRemote.value = false
+ finishRemoteProgress()
+ }
+}
+
+const handleRemoteRestore = async () => {
+ const entry = selectedRemoteBackup.value
+ if (!entry) return
+ if (!ensureRemoteConnectionReady()) return
+
+ try {
+ isRestoringRemote.value = true
+ startRemoteProgress('restore', 'dataManager.remote.progress.restore.start')
+ const servicesValue = services.value
+ if (!servicesValue) {
+ throw new Error('[DataManager] Services are not initialized. Make sure the application has started correctly.')
+ }
+ const result = await restoreRemoteSnapshotBackup({
+ objectStore: getRemoteStore(),
+ snapshotId: entry.id,
+ dataManager: getDataManager.value,
+ favoriteManager: servicesValue.favoriteManager,
+ imageStorageService: servicesValue.imageStorageService,
+ favoriteImageStorageService: servicesValue.favoriteImageStorageService,
+ sections: toRemoteRestoreSectionSelection(),
+ favoriteMergeStrategy: favoriteMergeStrategy.value,
+ onProgress: (event) => updateRemoteProgress('restore', event),
+ })
+ markGoogleDriveAuthorizationSuccess()
+ toast.success(t('dataManager.import.packageSuccess', {
+ restored: result.restored,
+ skipped: result.skipped,
+ }))
+ const warning = buildRemoteSnapshotRestoreWarning(result)
+ if (warning) {
+ toast.warning(warning)
+ }
+ emit('imported')
+ emit('close')
+ } catch (error) {
+ console.error('[DataManager] Remote restore failed:', error)
+ toast.error(t('dataManager.remote.restoreFailed', { message: (error as Error).message }))
+ } finally {
+ isRestoringRemote.value = false
+ finishRemoteProgress()
+ }
+}
+
+const handleRemoteCleanup = async () => {
+ if (!ensureRemoteConnectionReady()) return
+
+ try {
+ persistRemoteSettings(false)
+ isCleaningRemoteAssets.value = true
+ startRemoteProgress('cleanup', 'dataManager.remote.progress.cleanup.start')
+ const objectStore = getRemoteStore()
+ const analysis = await analyzeRemoteSnapshotAssetCleanup(objectStore, (event) =>
+ updateRemoteProgress('cleanup', event),
+ )
+ markGoogleDriveAuthorizationSuccess()
+
+ if (analysis.candidates.length === 0) {
+ toast.success(t('dataManager.remote.cleanupNothing'))
+ return
+ }
+
+ const confirmed = window.confirm(t('dataManager.remote.cleanupConfirm', {
+ count: analysis.candidates.length,
+ size: formatFileSize(analysis.totalCandidateBytes),
+ }))
+ if (!confirmed) return
+
+ const cleanup = await cleanupRemoteSnapshotAssets(objectStore, (event) =>
+ updateRemoteProgress('cleanup', event),
+ )
+ if (cleanup.failed.length > 0) {
+ toast.warning(t('dataManager.remote.cleanupPartial', {
+ deleted: cleanup.deleted,
+ failed: cleanup.failed.length,
+ }))
+ } else {
+ toast.success(t('dataManager.remote.cleanupSuccess', {
+ count: cleanup.deleted,
+ size: formatFileSize(cleanup.totalCandidateBytes),
+ }))
+ }
+ } catch (error) {
+ console.error('[DataManager] Remote asset cleanup failed:', error)
+ toast.error(t('dataManager.remote.cleanupFailed', { message: (error as Error).message }))
+ } finally {
+ isCleaningRemoteAssets.value = false
+ finishRemoteProgress()
+ }
+}
+
const openStorageDir = () => {
if (!isDesktopRuntime || !window.electronAPI?.data) return
window.electronAPI.data.openStorageDirectory()
@@ -474,6 +1578,10 @@ const toImportSectionSelection = (): DataManagerPackageSectionSelection => ({
availableImportSections.value.has('favoriteImages'),
})
+const toRemoteRestoreSectionSelection = (): DataManagerPackageSectionSelection => ({
+ ...DEFAULT_DATA_MANAGER_PACKAGE_SECTIONS,
+})
+
const resetImportSelection = () => {
importPreviewText.value = ''
availableImportSections.value = new Set(allPackageSections)
@@ -580,11 +1688,12 @@ const handleExport = async () => {
const url = URL.createObjectURL(exportPackage.blob)
const link = document.createElement('a')
link.href = url
- link.download = `prompt-optimizer-backup-${new Date().toISOString().split('T')[0]}.po-backup.zip`
+ link.download = createRemoteBackupFileName()
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
+ recordDataBackupCompleted()
if (exportPackage.missingResources.length > 0) {
toast.warning(t('dataManager.export.partialSuccess', {
@@ -626,6 +1735,32 @@ const buildPackageImportWarning = (result: DataManagerResourcePackageImportResul
return warnings.join('\n')
}
+const buildRemoteSnapshotRestoreWarning = (
+ result: {
+ missing: Array
+ corrupt: Array
+ errors: string[]
+ },
+): string => {
+ const warnings: string[] = []
+ if (result.missing.length > 0) {
+ warnings.push(t('dataManager.import.resourcesMissing', {
+ count: result.missing.length,
+ }))
+ }
+ if (result.corrupt.length > 0) {
+ warnings.push(t('dataManager.import.resourcesCorrupt', {
+ count: result.corrupt.length,
+ }))
+ }
+ if (result.errors.length > 0) {
+ warnings.push(t('dataManager.import.resourcesFailed', {
+ count: result.errors.length,
+ }))
+ }
+ return warnings.join('\n')
+}
+
const handleImport = async () => {
if (!selectedFile.value) return
@@ -716,15 +1851,33 @@ const getStorageItemDetail = (item: Pick)
diff --git a/packages/ui/src/components/app-layout/AppHeaderActions.vue b/packages/ui/src/components/app-layout/AppHeaderActions.vue
index c0c6a749..b68a576e 100644
--- a/packages/ui/src/components/app-layout/AppHeaderActions.vue
+++ b/packages/ui/src/components/app-layout/AppHeaderActions.vue
@@ -57,15 +57,18 @@
:ghost="false"
:round="true"
/>
-
+
+
+
(), {
favoritesActive: false,
+ backupReminderDue: false,
})
// ========================
diff --git a/packages/ui/src/components/app-layout/PromptOptimizerApp.vue b/packages/ui/src/components/app-layout/PromptOptimizerApp.vue
index 07178579..5a28b1ec 100644
--- a/packages/ui/src/components/app-layout/PromptOptimizerApp.vue
+++ b/packages/ui/src/components/app-layout/PromptOptimizerApp.vue
@@ -52,6 +52,7 @@
@open-data-manager="showDataManager = true"
@open-variables="handleOpenVariableManager()"
:favorites-active="isFavoritesRoute"
+ :backup-reminder-due="dataBackupReminderDue"
:app-version="appVersion"
@open-website="openOfficialWebsite"
@open-docs="openDocumentationSite"
@@ -331,6 +332,10 @@ import type { TemplateManagerTemplateType } from '../../composables/prompt/useTe
// Data Transformation
import { DataTransformer } from '../../utils/data-transformer'
+import {
+ DATA_BACKUP_STATUS_EVENT,
+ isDataBackupReminderDue,
+} from '../../utils/data-backup-reminder'
// Types
import type { ModelSelectOption, TestAreaPanelInstance } from '../../types'
@@ -639,6 +644,7 @@ const servicesForContextEditor = computed(() => services?.value || null);
// 6. 创建所有必要的引用
const promptService = shallowRef(null);
const showDataManager = ref(false);
+const dataBackupReminderDue = ref(isDataBackupReminderDue());
type ContextWorkspaceExpose = {
// Vue ComponentPublicInstance 会自动 unwrap expose 里的 Ref,因此这里使用已解包的类型
@@ -2272,11 +2278,18 @@ const handleVisibilityChange = () => {
}
}
+const refreshDataBackupReminder = () => {
+ dataBackupReminderDue.value = isDataBackupReminderDue()
+}
+
onMounted(() => {
// Route-level lazy loading can break after a new deployment when this tab is still running an old main bundle.
// Prompt user to refresh instead of auto-reloading.
if (typeof window !== 'undefined') {
window.addEventListener('unhandledrejection', handleUnhandledRejection);
+ window.addEventListener(DATA_BACKUP_STATUS_EVENT, refreshDataBackupReminder);
+ window.addEventListener('storage', refreshDataBackupReminder);
+ refreshDataBackupReminder();
}
removeRouterErrorHandler = routerInstance.onError((error) => {
if (!isChunkLoadFailure(error)) return;
@@ -2384,6 +2397,8 @@ onBeforeUnmount(async () => {
window.removeEventListener('pagehide', handlePagehide)
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('unhandledrejection', handleUnhandledRejection)
+ window.removeEventListener(DATA_BACKUP_STATUS_EVENT, refreshDataBackupReminder)
+ window.removeEventListener('storage', refreshDataBackupReminder)
if (hasRegisteredGlobalHistoryRefresh) {
window.removeEventListener(
'prompt-optimizer:history-refresh',
diff --git a/packages/ui/src/components/common/WorkspaceUtilityMenu.vue b/packages/ui/src/components/common/WorkspaceUtilityMenu.vue
index 6f6bcefe..d259b969 100644
--- a/packages/ui/src/components/common/WorkspaceUtilityMenu.vue
+++ b/packages/ui/src/components/common/WorkspaceUtilityMenu.vue
@@ -105,8 +105,7 @@
-
-
-
-
-
-
- {{ $t('dataManager.warning') }}
-
-
-
-
-
-
- {{ $t('dataManager.import.selectFile') }}
-
-
- {{ $t('dataManager.import.supportFormat') }}
-
-
-
-
-
- {{ $t('common.clear') }}
-
-
-
- {{ selectedFile.name }}
-
- {{ formatFileSize(selectedFile.file?.size ?? 0) }}
-
-
- {{ importPreviewText }}
-
-
-
-
+ {{ $t('dataManager.warning') }}
+
+
+
+
+
-
- {{ $t('dataManager.import.scopeTitle') }}
-
-
{{ isImporting ? $t('common.importing') : $t('dataManager.import.button') }}
+
+
+
-
-
-
+
+
+
+
+
+
+
-
+ {{ $t('dataManager.import.selectFile') }}
+
+ {{ $t('dataManager.import.supportFormat') }}
+
-
- {{ $t('dataManager.import.favoriteMergeStrategy') }}
-
-
-
+
-
-
-
-
-
-
+
+ {{ $t('common.clear') }}
+
-
+ {{ selectedFile.name }}
+
+ {{ formatFileSize(selectedFile.file?.size ?? 0) }}
+
+ · {{ importPreviewText }}
+
+
+
+
+
+
+ {{ $t('dataManager.import.settingsTitle') }}
+ +
+
+
+
+ {{ $t('dataManager.import.scopeTitle') }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t('dataManager.import.favoriteMergeStrategy') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('dataManager.remote.title') }}
+
+
+ {{ $t(remoteRecommendationKey) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('dataManager.remote.restoreSelected') }}
+
+
+
+ {{ $t('dataManager.remote.provider') }}
+
+
+
+
+
+
+
+
+
+
+ {{ currentRemoteConnectionLabel }}
+
+
+
+ {{ currentRemoteConnectionTitle }}
+
+ {{ currentRemoteConnectionMessage }}
+
+
+ {{ $t('dataManager.remote.lastCheckedAt', { time: new Date(currentRemoteConnection.lastCheckedAt).toLocaleString() }) }}
+
+
+
+
+ {{ $t('dataManager.remote.saveAndConnect') }}
+
+
+ {{
+ isRemoteConfigExpanded
+ ? $t('dataManager.remote.hideConfiguration')
+ : $t('dataManager.remote.editConfiguration')
+ }}
+
+
+
+
+ {{ googleDriveAuthorizationLabel }}
+
+
+
+
+ {{ $t('dataManager.remote.googleDrivePath', { path: `/${GOOGLE_DRIVE_DEFAULT_BACKUP_FOLDER_NAME}` }) }}
+
+
+
+
+
+
+
+
+
+ {{ $t('dataManager.remote.configuration') }}
+
+ {{
+ isCloudflareR2Configured
+ ? $t('dataManager.remote.cloudflareR2ConfiguredSummary', {
+ bucket: cloudflareR2BucketPreview,
+ prefix: `/${CLOUDFLARE_R2_DEFAULT_BACKUP_PREFIX}`,
+ })
+ : $t('dataManager.remote.cloudflareR2SetupSummary')
+ }}
+
+
+
+
+ {{
+ isCloudflareR2Configured
+ ? $t('dataManager.remote.configured')
+ : $t('dataManager.remote.setupRequired')
+ }}
+
+
+ {{
+ isCloudflareR2Configured
+ ? (
+ isCloudflareR2SetupGuideExpanded
+ ? $t('dataManager.remote.hideSetupGuide')
+ : $t('dataManager.remote.showSetupGuide')
+ )
+ : $t('dataManager.remote.showSetupGuide')
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('dataManager.remote.accountId') }}
+
+
+
+
+
+
+
+ {{ $t('dataManager.remote.cloudflareSteps.account.action') }}
+
+
+
+
+
+ {{ $t('dataManager.remote.bucket') }}
+
+
+
+
+
+
+
+ {{ $t('dataManager.remote.cloudflareSteps.bucket.action') }}
+
+
+
+
+
+ {{ $t('dataManager.remote.cloudflareSteps.bucket.corsAction') }}
+
+
+
+
+ {{ $t('dataManager.remote.cloudflareEndpoint', { endpoint: cloudflareR2EndpointPreview || 'https://.r2.cloudflarestorage.com' }) }}
+
+
+ {{ $t('dataManager.remote.cloudflareBucketPreview', { bucket: cloudflareR2BucketPreview }) }}
+
+
+ {{ $t('dataManager.remote.cloudflareObjectPrefix', { prefix: `/${CLOUDFLARE_R2_DEFAULT_BACKUP_PREFIX}` }) }}
+
+
+
+
+
+
+
+ {{ $t('dataManager.remote.accessKey') }}
+
+
+
+ {{ $t('dataManager.remote.secretKey') }}
+
+
+
+
+
+
+
+ {{ $t('dataManager.remote.cloudflareSteps.credentials.tokenAction') }}
+
+
+
+
+
+ {{ $t('dataManager.remote.cloudflareSteps.credentials.docsAction') }}
+
+
+
+
+ {{ $t('dataManager.remote.forcePathStyle') }}
+
+
+ {{ isDesktopRuntime ? $t('dataManager.remote.s3DesktopHelp') : $t('dataManager.remote.s3WebHelp') }}
+
+
+
+
+
+
+
+
+ {{ $t('dataManager.remote.endpoint') }}
+
+
+
+ {{ $t('dataManager.remote.bucket') }}
+
+
+
+ {{ $t('dataManager.remote.region') }}
+
+
+
+ {{ $t('dataManager.remote.prefix') }}
+
+
+
+ {{ $t('dataManager.remote.accessKey') }}
+
+
+
+ {{ $t('dataManager.remote.secretKey') }}
+
+
+
+
+ {{ isDesktopRuntime ? $t('dataManager.remote.webdavDesktopHelp') : $t('dataManager.remote.webdavWebHelp') }}
+
+
+
+
+
+ {{ $t('dataManager.remote.endpoint') }}
+
+
+
+
+
+ {{ $t('dataManager.remote.username') }}
+
+
+
+ {{ $t('dataManager.remote.password') }}
+
+
+
+ {{ $t('dataManager.remote.directory') }}
+
+
+
+
+
+
+
+ {{ $t('dataManager.remote.backupNow') }}
+
+
+
+
+
+ {{ $t('dataManager.remote.refreshList') }}
+
+
+
+
+
+ {{ $t('dataManager.remote.cleanupAssets') }}
+
+
+
+
+
+ {{ remoteProgress.message }}
+
+
+
+
+
+ {{ $t('dataManager.remote.restoreFrom') }}
+
+
+
+
+
+
+ {{ $t('dataManager.import.favoriteMergeStrategy') }}
+
+
+
+
+
+
+
+
+
+
+