From b0680cda7e2e71f722ccef2f7f215bb71526b828 Mon Sep 17 00:00:00 2001 From: linshen <32978552+linshenkx@users.noreply.github.com> Date: Fri, 8 May 2026 21:17:23 +0800 Subject: [PATCH] feat: add deployment-aware remote backups --- env.local.example | 9 + packages/desktop/main.js | 5 + packages/desktop/package.json | 5 +- packages/desktop/preload.js | 12 + packages/desktop/remote-storage.js | 515 +++++ packages/ui/package.json | 1 + packages/ui/src/components/DataManager.vue | 1937 +++++++++++++++-- .../app-layout/AppHeaderActions.vue | 25 +- .../app-layout/PromptOptimizerApp.vue | 15 + .../common/WorkspaceUtilityMenu.vue | 3 +- packages/ui/src/i18n/locales/en-US/context.ts | 196 +- packages/ui/src/i18n/locales/zh-CN/context.ts | 198 +- packages/ui/src/i18n/locales/zh-TW/context.ts | 198 +- packages/ui/src/types/electron.d.ts | 3 + packages/ui/src/utils/data-backup-reminder.ts | 46 + packages/ui/src/utils/remote-backup.ts | 1732 +++++++++++++++ .../ui/src/utils/remote-snapshot-backup.ts | 845 +++++++ .../unit/components/AppHeaderActions.spec.ts | 66 + .../tests/unit/components/DataManager.spec.ts | 366 +++- .../unit/utils/data-backup-reminder.spec.ts | 41 + .../ui/tests/unit/utils/remote-backup.spec.ts | 833 +++++++ .../unit/utils/remote-snapshot-backup.spec.ts | 395 ++++ pnpm-lock.yaml | 1305 +++++++++++ scripts/desktop-ipc-handlers.test.mjs | 146 +- 24 files changed, 8561 insertions(+), 336 deletions(-) create mode 100644 packages/desktop/remote-storage.js create mode 100644 packages/ui/src/utils/data-backup-reminder.ts create mode 100644 packages/ui/src/utils/remote-backup.ts create mode 100644 packages/ui/src/utils/remote-snapshot-backup.ts create mode 100644 packages/ui/tests/unit/utils/data-backup-reminder.spec.ts create mode 100644 packages/ui/tests/unit/utils/remote-backup.spec.ts create mode 100644 packages/ui/tests/unit/utils/remote-snapshot-backup.spec.ts diff --git a/env.local.example b/env.local.example index 47eae6bc..adaf49c0 100644 --- a/env.local.example +++ b/env.local.example @@ -30,6 +30,15 @@ # VITE_ENABLE_PROMPT_GARDEN_IMPORT=1 # VITE_PROMPT_GARDEN_BASE_URL=https://garden.always200.com +# Google Drive 远程 ZIP 备份配置 +# 用于数据管理中的“授权 Google Drive”按钮。Client ID 是应用的 OAuth +# 公开标识,由部署者在 Google Cloud Console 创建“Web 应用”OAuth 客户端后获得。 +# 普通用户不需要理解或填写它;自部署时需要把当前访问域名加入该 OAuth +# 客户端的 Authorized JavaScript origins,例如 http://localhost:18181 +# 或 https://your-domain.example。 +# VITE_GOOGLE_DRIVE_CLIENT_ID=your-google-oauth-web-client-id.apps.googleusercontent.com +# Desktop 版暂不支持 Google Drive,推荐使用 Cloudflare R2 / S3 / WebDAV。 + # DeepSeek API 配置 # VITE_DEEPSEEK_API_KEY=sk-your-deepseek-api-key-here diff --git a/packages/desktop/main.js b/packages/desktop/main.js index 3766b9b0..ed3da0a5 100644 --- a/packages/desktop/main.js +++ b/packages/desktop/main.js @@ -32,6 +32,7 @@ const { DEFAULT_CONFIG } = require('./config/update-config'); const { createGlobalDispatcherFromProxyDecision } = require('./config/proxy-dispatcher'); +const { setupRemoteStorageHandlers } = require('./remote-storage'); const path = require('path'); // 确定正确的配置文件路径 @@ -854,6 +855,10 @@ function createFavoriteErrorResponse(error) { function setupIPC() { console.log('[Main Process] Setting up high-level service IPC handlers...'); setupPreferenceHandlers(); + setupRemoteStorageHandlers(ipcMain, { + createSuccessResponse, + createErrorResponse, + }); // LLM Service handlers ipcMain.handle('llm-testConnection', async (event, provider) => { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index a6a03a0c..b6d79e1c 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -23,11 +23,13 @@ "electron-builder": "^26.8.2" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1044.0", "@prompt-optimizer/core": "workspace:*", "dotenv": "^17.3.1", "electron-log": "^5.4.3", "electron-updater": "6.8.3", - "undici": "^7.24.6" + "undici": "^7.24.6", + "webdav": "^5.10.0" }, "build": { "appId": "com.promptoptimizer.desktop", @@ -38,6 +40,7 @@ "files": [ "main.js", "preload.js", + "remote-storage.js", "config/**/*", "web-dist/**/*", "node_modules/**/*" diff --git a/packages/desktop/preload.js b/packages/desktop/preload.js index 5a5a2efd..17c4769c 100644 --- a/packages/desktop/preload.js +++ b/packages/desktop/preload.js @@ -20,6 +20,8 @@ const IPC_EVENTS = { UPDATE_DOWNLOAD_STARTED: 'updater-download-started' }; +const REMOTE_STORAGE_CHANNEL = 'remote-storage:invoke'; + // 简单的超时包装器,避免过度设计 const withTimeout = (promise, timeoutMs = 30000) => { return Promise.race([ @@ -1085,6 +1087,16 @@ contextBridge.exposeInMainWorld('electronAPI', { } }, + remoteStorage: { + invoke: async (request) => { + const result = await ipcRenderer.invoke(REMOTE_STORAGE_CHANNEL, request); + if (!result.success) { + throw createIpcError(result.error); + } + return result.data; + }, + }, + // Context Repository interface context: { list: async () => { diff --git a/packages/desktop/remote-storage.js b/packages/desktop/remote-storage.js new file mode 100644 index 00000000..4f655091 --- /dev/null +++ b/packages/desktop/remote-storage.js @@ -0,0 +1,515 @@ +const { + DeleteObjectCommand, + GetObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client, +} = require('@aws-sdk/client-s3'); + +const REMOTE_STORAGE_CHANNEL = 'remote-storage:invoke'; +const JSON_MIME_TYPE = 'application/json'; +const CLOUDFLARE_R2_DEFAULT_BACKUP_PREFIX = 'prompt-optimizer-backups/'; +let webDavModulePromise = null; + +const joinRemotePath = (...parts) => + parts + .map((part) => String(part || '').replace(/^\/+|\/+$/g, '')) + .filter(Boolean) + .join('/'); + +const normalizeObjectPath = (path) => joinRemotePath(path); + +const parentPathOf = (path) => { + const normalized = normalizeObjectPath(path); + const index = normalized.lastIndexOf('/'); + return index === -1 ? '' : normalized.slice(0, index); +}; + +const normalizeDirectoryPrefix = (path) => { + const normalized = normalizeObjectPath(path); + return normalized ? `${normalized}/` : ''; +}; + +const copyUint8Array = (bytes) => { + const view = new Uint8Array(bytes.byteLength); + view.set(bytes); + return view; +}; + +const toUint8Array = (body) => { + if (body === undefined || body === null) return new Uint8Array(0); + if (body instanceof Uint8Array) return copyUint8Array(body); + if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0)); + if (ArrayBuffer.isView(body)) { + const view = new Uint8Array(body.buffer, body.byteOffset, body.byteLength); + return copyUint8Array(view); + } + if (typeof body === 'string') return new TextEncoder().encode(body); + if (Array.isArray(body)) return new Uint8Array(body); + throw new Error('Remote storage body must be a string, ArrayBuffer, or Uint8Array'); +}; + +const toArrayBuffer = (bytes) => { + const view = toUint8Array(bytes); + return view.buffer; +}; + +const streamToUint8Array = async (stream) => { + const chunks = []; + let total = 0; + for await (const chunk of stream) { + const bytes = toUint8Array(chunk); + chunks.push(bytes); + total += bytes.byteLength; + } + const result = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; +}; + +const responseBodyToArrayBuffer = async (body) => { + if (!body) return new ArrayBuffer(0); + if (body instanceof ArrayBuffer) return body.slice(0); + if (body instanceof Uint8Array || ArrayBuffer.isView(body)) return toArrayBuffer(body); + if (typeof body === 'string') return toArrayBuffer(body); + + if (typeof body.transformToByteArray === 'function') { + return toArrayBuffer(await body.transformToByteArray()); + } + + if (typeof body.arrayBuffer === 'function') { + return await body.arrayBuffer(); + } + + if (typeof body.transformToString === 'function') { + return toArrayBuffer(await body.transformToString()); + } + + if (typeof body[Symbol.asyncIterator] === 'function') { + return toArrayBuffer(await streamToUint8Array(body)); + } + + throw new Error('Remote storage download returned an unsupported response body'); +}; + +const isNotFoundError = (error) => { + const value = error || {}; + return value?.$metadata?.httpStatusCode === 404 || + value?.status === 404 || + value?.statusCode === 404 || + value?.name === 'NotFound' || + value?.name === 'NoSuchKey' || + value?.Code === 'NoSuchKey' || + value?.code === 'NoSuchKey' || + value?.code === 'ERR_OBJECT_NOT_FOUND'; +}; + +const errorMessage = (error) => error?.message || String(error); + +const toCloudflareR2S3Config = (config) => ({ + kind: 's3-compatible', + endpoint: String(config.accountId || '').trim() + ? `https://${String(config.accountId || '').trim()}.r2.cloudflarestorage.com` + : '', + region: 'auto', + bucket: config.bucket, + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + prefix: CLOUDFLARE_R2_DEFAULT_BACKUP_PREFIX, + forcePathStyle: true, +}); + +const createDefaultDependencies = () => ({ + S3Client, + DeleteObjectCommand, + GetObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + openExternal: async (url) => { + const { shell } = require('electron'); + await shell.openExternal(url); + }, + createWebDavClient: async (endpoint, options) => { + if (!webDavModulePromise) { + webDavModulePromise = Promise.resolve().then(() => require('webdav')); + } + const webdav = await webDavModulePromise; + return webdav.createClient(endpoint, options); + }, +}); + +class S3RemoteObjectStore { + constructor(config, dependencies) { + this.config = config; + this.dependencies = dependencies; + this.assertConfigured(); + this.client = new dependencies.S3Client({ + endpoint: config.endpoint, + region: config.region || 'auto', + forcePathStyle: config.forcePathStyle !== false, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + }); + } + + async exists(path) { + return Boolean(await this.head(path)); + } + + async head(path) { + const normalized = normalizeObjectPath(path); + try { + const response = await this.client.send(new this.dependencies.HeadObjectCommand({ + Bucket: this.config.bucket, + Key: this.keyForPath(normalized), + })); + return { + path: normalized, + sizeBytes: typeof response.ContentLength === 'number' ? response.ContentLength : undefined, + updatedAt: response.LastModified instanceof Date ? response.LastModified.toISOString() : undefined, + contentType: typeof response.ContentType === 'string' ? response.ContentType : undefined, + }; + } catch (error) { + if (isNotFoundError(error)) return null; + throw new Error(`S3 metadata lookup failed: ${errorMessage(error)}`, { cause: error }); + } + } + + async put(path, body, options = {}) { + const normalized = normalizeObjectPath(path); + const bytes = toUint8Array(body); + const contentType = options.contentType || JSON_MIME_TYPE; + try { + await this.client.send(new this.dependencies.PutObjectCommand({ + Bucket: this.config.bucket, + Key: this.keyForPath(normalized), + Body: bytes, + ContentType: contentType, + })); + } catch (error) { + throw new Error(`S3 upload failed: ${errorMessage(error)}`, { cause: error }); + } + return { + path: normalized, + sizeBytes: bytes.byteLength, + updatedAt: new Date().toISOString(), + contentType, + }; + } + + async get(path) { + const normalized = normalizeObjectPath(path); + try { + const response = await this.client.send(new this.dependencies.GetObjectCommand({ + Bucket: this.config.bucket, + Key: this.keyForPath(normalized), + })); + return responseBodyToArrayBuffer(response.Body); + } catch (error) { + if (isNotFoundError(error)) { + throw new Error(`S3 object not found: ${normalized}`, { cause: error }); + } + throw new Error(`S3 download failed: ${errorMessage(error)}`, { cause: error }); + } + } + + async list(prefix) { + const entries = []; + let continuationToken; + try { + do { + const response = await this.client.send(new this.dependencies.ListObjectsV2Command({ + Bucket: this.config.bucket, + Prefix: this.listPrefixForPath(prefix), + ContinuationToken: continuationToken, + })); + for (const object of response.Contents || []) { + const key = object.Key || ''; + const path = this.pathFromKey(key); + if (!path) continue; + entries.push({ + path, + sizeBytes: typeof object.Size === 'number' ? object.Size : undefined, + updatedAt: object.LastModified instanceof Date ? object.LastModified.toISOString() : undefined, + }); + } + continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; + } while (continuationToken); + } catch (error) { + throw new Error(`S3 list failed: ${errorMessage(error)}`, { cause: error }); + } + return entries.sort((a, b) => a.path.localeCompare(b.path)); + } + + async delete(path) { + try { + await this.client.send(new this.dependencies.DeleteObjectCommand({ + Bucket: this.config.bucket, + Key: this.keyForPath(path), + })); + } catch (error) { + throw new Error(`S3 delete failed: ${errorMessage(error)}`, { cause: error }); + } + } + + prefix() { + return joinRemotePath(this.config.prefix || 'prompt-optimizer-backups'); + } + + keyForPath(path) { + return joinRemotePath(this.prefix(), path); + } + + listPrefixForPath(path) { + return normalizeDirectoryPrefix(this.keyForPath(path)); + } + + pathFromKey(key) { + const prefix = this.prefix(); + return key === prefix + ? '' + : key.startsWith(`${prefix}/`) + ? key.slice(prefix.length + 1) + : key; + } + + assertConfigured() { + if (!this.config.endpoint || !this.config.bucket || !this.config.accessKeyId || !this.config.secretAccessKey) { + throw new Error('S3 endpoint, bucket, access key, and secret key are required'); + } + } +} + +class WebDavRemoteObjectStore { + constructor(config, dependencies) { + this.config = config; + this.dependencies = dependencies; + this.clientPromise = null; + } + + async exists(path) { + return Boolean(await this.head(path)); + } + + async head(path) { + const normalized = normalizeObjectPath(path); + const client = await this.getClient(); + try { + const stat = await client.stat(this.filePath(normalized)); + return this.toEntry(normalized, stat); + } catch (error) { + if (isNotFoundError(error)) return null; + throw new Error(`WebDAV metadata lookup failed: ${errorMessage(error)}`, { cause: error }); + } + } + + async put(path, body, options = {}) { + const normalized = normalizeObjectPath(path); + const bytes = toUint8Array(body); + const client = await this.getClient(); + await this.ensureDirectoryPath(parentPathOf(normalized)); + try { + await client.putFileContents(this.filePath(normalized), Buffer.from(bytes), { + contentLength: bytes.byteLength, + overwrite: true, + headers: options.contentType ? { 'Content-Type': options.contentType } : undefined, + }); + } catch (error) { + throw new Error(`WebDAV upload failed: ${errorMessage(error)}`, { cause: error }); + } + return { + path: normalized, + sizeBytes: bytes.byteLength, + updatedAt: new Date().toISOString(), + contentType: options.contentType, + }; + } + + async get(path) { + const normalized = normalizeObjectPath(path); + const client = await this.getClient(); + try { + const contents = await client.getFileContents(this.filePath(normalized), { format: 'binary' }); + return responseBodyToArrayBuffer(contents); + } catch (error) { + if (isNotFoundError(error)) { + throw new Error(`WebDAV object not found: ${normalized}`, { cause: error }); + } + throw new Error(`WebDAV download failed: ${errorMessage(error)}`, { cause: error }); + } + } + + async list(prefix) { + const normalizedPrefix = normalizeObjectPath(prefix); + const client = await this.getClient(); + await this.ensureDirectoryPath(normalizedPrefix); + try { + const contents = await client.getDirectoryContents(this.directoryPath(normalizedPrefix), { + deep: true, + details: true, + }); + const items = Array.isArray(contents) ? contents : contents?.data || []; + return items + .filter((item) => item && item.type !== 'directory') + .map((item) => this.toEntryFromRemotePath(item.filename || item.basename || '', item)) + .filter(Boolean) + .sort((a, b) => a.path.localeCompare(b.path)); + } catch (error) { + throw new Error(`WebDAV list failed: ${errorMessage(error)}`, { cause: error }); + } + } + + async delete(path) { + const normalized = normalizeObjectPath(path); + const client = await this.getClient(); + try { + await client.deleteFile(this.filePath(normalized)); + } catch (error) { + if (isNotFoundError(error)) return; + throw new Error(`WebDAV delete failed: ${errorMessage(error)}`, { cause: error }); + } + } + + async ensureDirectoryPath(path) { + const client = await this.getClient(); + const normalized = normalizeObjectPath(path); + try { + await client.createDirectory(this.directoryPath(''), { recursive: true }); + if (normalized) { + await client.createDirectory(this.directoryPath(normalized), { recursive: true }); + } + } catch (error) { + if (isNotFoundError(error)) { + throw new Error(`WebDAV directory creation failed: ${errorMessage(error)}`, { cause: error }); + } + if (!/exists|already/i.test(errorMessage(error))) { + throw new Error(`WebDAV directory creation failed: ${errorMessage(error)}`, { cause: error }); + } + } + } + + async getClient() { + if (!this.clientPromise) { + if (!this.config.endpoint) throw new Error('WebDAV endpoint is required'); + this.clientPromise = Promise.resolve(this.dependencies.createWebDavClient( + this.config.endpoint, + { + username: this.config.username || undefined, + password: this.config.password || undefined, + }, + )); + } + return this.clientPromise; + } + + directoryPath(path) { + return `/${joinRemotePath(this.config.directory || 'prompt-optimizer-backups', path)}`; + } + + filePath(path) { + return this.directoryPath(path); + } + + toEntry(path, item = {}) { + const sizeValue = item.size ?? item.props?.getcontentlength; + const updatedValue = item.lastmod ?? item.lastModified ?? item.props?.getlastmodified; + return { + path: normalizeObjectPath(path), + sizeBytes: typeof sizeValue === 'number' ? sizeValue : Number.isFinite(Number(sizeValue)) ? Number(sizeValue) : undefined, + updatedAt: updatedValue ? new Date(updatedValue).toISOString() : undefined, + contentType: item.mime || item.props?.getcontenttype || undefined, + }; + } + + toEntryFromRemotePath(remotePath, item) { + const root = this.directoryPath('').replace(/\/+$/g, ''); + const decodedRemotePath = decodeURIComponent(String(remotePath || '')); + const withoutRoot = decodedRemotePath.startsWith(`${root}/`) + ? decodedRemotePath.slice(root.length + 1) + : decodedRemotePath.replace(/^\/+/, ''); + if (!withoutRoot) return null; + return this.toEntry(withoutRoot, item); + } +} + +const createRemoteObjectStore = (provider, dependencies = createDefaultDependencies()) => { + if (!provider || typeof provider !== 'object') { + throw new Error('Remote storage provider config is required'); + } + if (provider.kind === 'google-drive') { + throw new Error('Google Drive remote backup is only supported in the Web version'); + } + if (provider.kind === 'cloudflare-r2') { + return new S3RemoteObjectStore(toCloudflareR2S3Config(provider), dependencies); + } + if (provider.kind === 's3-compatible') { + return new S3RemoteObjectStore(provider, dependencies); + } + if (provider.kind === 'webdav') { + return new WebDavRemoteObjectStore(provider, dependencies); + } + throw new Error(`Remote storage provider is not supported by Desktop IPC: ${provider.kind}`); +}; + +const handleRemoteStorageOperation = async (request, dependencies = createDefaultDependencies()) => { + if (!request || typeof request !== 'object') { + throw new Error('Remote storage request is required'); + } + const store = createRemoteObjectStore(request.provider, dependencies); + const operation = request.operation; + const path = normalizeObjectPath(request.path || ''); + + if (operation === 'authorize') { + if (typeof store.authorize === 'function') await store.authorize(); + return null; + } + if (operation === 'head') return store.head(path); + if (operation === 'exists') return store.exists(path); + if (operation === 'put') { + return store.put(path, request.body, { contentType: request.contentType }); + } + if (operation === 'get') return store.get(path); + if (operation === 'getText') { + return new TextDecoder().decode(await store.get(path)); + } + if (operation === 'list') return store.list(path); + if (operation === 'delete') { + await store.delete(path); + return null; + } + + throw new Error(`Unsupported remote storage operation: ${operation}`); +}; + +function setupRemoteStorageHandlers(ipcMain, helpers = {}, dependencies) { + const createSuccessResponse = helpers.createSuccessResponse || ((data) => ({ success: true, data })); + const createErrorResponse = helpers.createErrorResponse || ((error) => ({ + success: false, + error: { message: error?.message || String(error) }, + })); + + ipcMain.handle(REMOTE_STORAGE_CHANNEL, async (_event, request) => { + try { + const result = await handleRemoteStorageOperation(request, dependencies); + return createSuccessResponse(result); + } catch (error) { + return createErrorResponse(error); + } + }); +} + +module.exports = { + REMOTE_STORAGE_CHANNEL, + createDefaultDependencies, + createRemoteObjectStore, + handleRemoteStorageOperation, + setupRemoteStorageHandlers, +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 978bc40a..76e823dc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -33,6 +33,7 @@ "lint:fix": "eslint src --ext .ts,.vue --fix" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1044.0", "@codemirror/autocomplete": "^6.20.1", "@codemirror/language": "^6.12.3", "@codemirror/state": "^6.6.0", diff --git a/packages/ui/src/components/DataManager.vue b/packages/ui/src/components/DataManager.vue index 842b2287..15676ffc 100644 --- a/packages/ui/src/components/DataManager.vue +++ b/packages/ui/src/components/DataManager.vue @@ -3,6 +3,7 @@ :show="show" preset="card" :style="dataManagerModalStyle" + class="data-manager-modal" :title="$t('dataManager.title')" size="large" :bordered="false" @@ -71,46 +72,35 @@ -
-
-
-
- -
-
- {{ $t('dataManager.backupWorkspace.exportTitle') }} - - {{ $t('dataManager.export.description') }} - -
-
-
- - {{ $t('dataManager.export.scopeTitle') }} +
+
+
+ + {{ $t('dataManager.backupWorkspace.exportTitle') }} -
- - - -
+ + {{ $t('dataManager.export.description') }} + +
+
+ + +
{{ isExporting ? $t('common.exporting') : $t('dataManager.export.button') }} -
+
-
-
-
- -
-
- {{ $t('dataManager.backupWorkspace.importTitle') }} - - {{ $t('dataManager.import.description') }} - -
+
+ +
+
+ + {{ $t('dataManager.backupWorkspace.importTitle') }} + + + {{ $t('dataManager.import.description') }} +
-
-
- - -
- - - {{ $t('dataManager.import.selectFile') }} - - - {{ $t('dataManager.import.supportFormat') }} - -
- -
-
- {{ selectedFile.name }} - - {{ formatFileSize(selectedFile.file?.size ?? 0) }} - - - {{ importPreviewText }} - -
- - {{ $t('common.clear') }} - -
-
-
- - - {{ $t('dataManager.warning') }} - -
- -
-
- - {{ $t('dataManager.import.scopeTitle') }} - -
- - - + + +
+ +
+ {{ $t('dataManager.import.selectFile') }} + + {{ $t('dataManager.import.supportFormat') }} +
-
- - {{ $t('dataManager.import.favoriteMergeStrategy') }} - - -
- - - -
-
+
+
+ {{ selectedFile.name }} + + {{ formatFileSize(selectedFile.file?.size ?? 0) }} + + +
+ + {{ $t('common.clear') }} +
-
-
+ + {{ isImporting ? $t('common.importing') : $t('dataManager.import.button') }} +
+ +
+ {{ $t('dataManager.import.settingsTitle') }} + +
+
+ + {{ $t('dataManager.import.scopeTitle') }} + +
+ + + +
+
+ +
+ + {{ $t('dataManager.import.favoriteMergeStrategy') }} + + +
+ + + +
+
+
+
+
+ + + {{ $t('dataManager.warning') }} + +
+ + +
+
+
+ + {{ $t('dataManager.remote.title') }} + + + {{ $t(remoteRecommendationKey) }} + +
+
+ +
+
+
+ + {{ $t('dataManager.remote.provider') }} + + +
+ +
+
+
+ {{ currentRemoteConnectionTitle }} + + {{ currentRemoteConnectionMessage }} + + + {{ $t('dataManager.remote.lastCheckedAt', { time: new Date(currentRemoteConnection.lastCheckedAt).toLocaleString() }) }} + +
+ + {{ currentRemoteConnectionLabel }} + +
+
+ + {{ $t('dataManager.remote.saveAndConnect') }} + + + {{ + isRemoteConfigExpanded + ? $t('dataManager.remote.hideConfiguration') + : $t('dataManager.remote.editConfiguration') + }} + +
+
+ + + + + + + + + +
+ +
+
+ + + {{ $t('dataManager.remote.backupNow') }} + + + + {{ $t('dataManager.remote.refreshList') }} + + + + {{ $t('dataManager.remote.cleanupAssets') }} + +
+
+ + + {{ remoteProgress.message }} + +
+ +
+ + {{ $t('dataManager.remote.restoreFrom') }} + + +
+ +
+ + {{ $t('dataManager.import.favoriteMergeStrategy') }} + + +
+ + + +
+
+
+ + + + {{ $t('dataManager.remote.restoreSelected') }} + +
@@ -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 @@