diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 5210d22ae4..d02cd15be8 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -15,7 +15,12 @@ export enum IpcChannel { App_SetAutoUpdate = 'app:set-auto-update', App_SetFeedUrl = 'app:set-feed-url', App_HandleZoomFactor = 'app:handle-zoom-factor', - + App_Select = 'app:select', + App_HasWritePermission = 'app:has-write-permission', + App_Copy = 'app:copy', + App_SetStopQuitApp = 'app:set-stop-quit-app', + App_SetAppDataPath = 'app:set-app-data-path', + App_RelaunchApp = 'app:relaunch-app', App_IsBinaryExist = 'app:is-binary-exist', App_GetBinaryPath = 'app:get-binary-path', App_InstallUvBinary = 'app:install-uv-binary', diff --git a/src/main/config.ts b/src/main/config.ts index 84dc1b846d..40e4ac2e90 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -1,7 +1,6 @@ import { app } from 'electron' import { getDataPath } from './utils' - const isDev = process.env.NODE_ENV === 'development' if (isDev) { diff --git a/src/main/index.ts b/src/main/index.ts index 3272887aa5..102264317a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,7 @@ import '@main/config' import { electronApp, optimizer } from '@electron-toolkit/utils' +import { initAppDataDir } from '@main/utils/file' import { replaceDevtoolsFont } from '@main/utils/windowUtil' import { app } from 'electron' import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer' @@ -20,8 +21,8 @@ import selectionService, { initSelectionService } from './services/SelectionServ import { registerShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' import { windowService } from './services/WindowService' -import { setUserDataDir } from './utils/file' +initAppDataDir() Logger.initialize() /** @@ -72,9 +73,6 @@ if (!app.requestSingleInstanceLock()) { app.quit() process.exit(0) } else { - // Portable dir must be setup before app ready - setUserDataDir() - // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 4da8e6c062..c759a52906 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -7,7 +7,7 @@ import { handleZoomFactor } from '@main/utils/zoom' import { FeedUrl } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import { Shortcut, ThemeMode } from '@types' -import { BrowserWindow, ipcMain, session, shell } from 'electron' +import { dialog, BrowserWindow, ipcMain, session, shell } from 'electron' import log from 'electron-log' import { Notification } from 'src/renderer/src/types/notification' @@ -34,7 +34,7 @@ import { setOpenLinkExternal } from './services/WebviewService' import { windowService } from './services/WindowService' import { calculateDirectorySize, getResourcePath } from './utils' import { decrypt, encrypt } from './utils/aes' -import { getCacheDir, getConfigDir, getFilesDir } from './utils/file' +import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateConfig } from './utils/file' import { compress, decompress } from './utils/zip' const fileManager = new FileStorage() @@ -175,6 +175,70 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { } }) + let preventQuitListener: ((event: Electron.Event) => void) | null = null + ipcMain.handle(IpcChannel.App_SetStopQuitApp, (_, stop: boolean = false, reason: string = '') => { + if (stop) { + // Only add listener if not already added + if (!preventQuitListener) { + preventQuitListener = (event: Electron.Event) => { + event.preventDefault() + notificationService.sendNotification({ + title: reason, + message: reason + } as Notification) + } + app.on('before-quit', preventQuitListener) + } + } else { + // Remove listener if it exists + if (preventQuitListener) { + app.removeListener('before-quit', preventQuitListener) + preventQuitListener = null + } + } + }) + + // Select app data path + ipcMain.handle(IpcChannel.App_Select, async (_, options: Electron.OpenDialogOptions) => { + try { + const { canceled, filePaths } = await dialog.showOpenDialog(options) + if (canceled || filePaths.length === 0) { + return null + } + return filePaths[0] + } catch (error: any) { + log.error('Failed to select app data path:', error) + return null + } + }) + + ipcMain.handle(IpcChannel.App_HasWritePermission, async (_, filePath: string) => { + return hasWritePermission(filePath) + }) + + // Set app data path + ipcMain.handle(IpcChannel.App_SetAppDataPath, async (_, filePath: string) => { + updateConfig(filePath) + app.setPath('userData', filePath) + }) + + // Copy user data to new location + ipcMain.handle(IpcChannel.App_Copy, async (_, oldPath: string, newPath: string) => { + try { + await fs.promises.cp(oldPath, newPath, { recursive: true }) + return { success: true } + } catch (error: any) { + log.error('Failed to copy user data:', error) + return { success: false, error: error.message } + } + }) + + // Relaunch app + ipcMain.handle(IpcChannel.App_RelaunchApp, () => { + app.relaunch() + app.exit(0) + }) + // check for update ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => { return await appUpdater.checkForUpdates() diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index f23809f1e4..e994e90bed 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -9,6 +9,7 @@ import StreamZip from 'node-stream-zip' import * as path from 'path' import { CreateDirectoryOptions, FileStat } from 'webdav' +import { getDataPath } from '../utils' import WebDav from './WebDav' import { windowService } from './WindowService' @@ -253,7 +254,7 @@ class BackupManager { Logger.log('[backup] step 3: restore Data directory') // 恢复 Data 目录 const sourcePath = path.join(this.tempDir, 'Data') - const destPath = path.join(app.getPath('userData'), 'Data') + const destPath = getDataPath() const dataExists = await fs.pathExists(sourcePath) const dataFiles = dataExists ? await fs.readdir(sourcePath) : [] diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index d18f5d0a84..62b2bba08f 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -25,12 +25,12 @@ import Embeddings from '@main/embeddings/Embeddings' import { addFileLoader } from '@main/loader' import Reranker from '@main/reranker/Reranker' import { windowService } from '@main/services/WindowService' +import { getDataPath } from '@main/utils' import { getAllFiles } from '@main/utils/file' import { MB } from '@shared/config/constant' import type { LoaderReturn } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types' -import { app } from 'electron' import Logger from 'electron-log' import { v4 as uuidv4 } from 'uuid' @@ -88,7 +88,7 @@ const loaderTaskIntoOfSet = (loaderTask: LoaderTask): LoaderTaskOfSet => { } class KnowledgeService { - private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase') + private storageDir = path.join(getDataPath(), 'KnowledgeBase') // Byte based private workload = 0 private processingItemCount = 0 diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index f01a6d47bf..737dcee0ba 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import { isMac } from '@main/constant' +import { isPortable } from '@main/constant' import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant' import { FileType, FileTypes } from '@types' import { app } from 'electron' @@ -23,6 +23,61 @@ function initFileTypeMap() { // 初始化映射表 initFileTypeMap() +export function hasWritePermission(path: string) { + try { + fs.accessSync(path, fs.constants.W_OK) + return true + } catch (error) { + return false + } +} + +function getAppDataPathFromConfig() { + try { + const configPath = path.join(getConfigDir(), 'config.json') + if (fs.existsSync(configPath)) { + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + if (config.appDataPath && fs.existsSync(config.appDataPath) && hasWritePermission(config.appDataPath)) { + return config.appDataPath + } + } + } catch (error) { + return null + } + return null +} + +export function initAppDataDir() { + const appDataPath = getAppDataPathFromConfig() + if (appDataPath) { + app.setPath('userData', appDataPath) + return + } + + if (isPortable) { + const portableDir = process.env.PORTABLE_EXECUTABLE_DIR + app.setPath('userData', path.join(portableDir || app.getPath('exe'), 'data')) + return + } +} + +export function updateConfig(appDataPath: string) { + const configDir = getConfigDir() + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }) + } + + const configPath = path.join(getConfigDir(), 'config.json') + if (!fs.existsSync(configPath)) { + fs.writeFileSync(configPath, JSON.stringify({ appDataPath }, null, 2)) + return + } + + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + config.appDataPath = appDataPath + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)) +} + export function getFileType(ext: string): FileTypes { ext = ext.toLowerCase() return fileTypeMap.get(ext) || FileTypes.OTHER @@ -88,12 +143,3 @@ export function getCacheDir() { export function getAppConfigDir(name: string) { return path.join(getConfigDir(), name) } - -export function setUserDataDir() { - if (!isMac) { - const dir = path.join(path.dirname(app.getPath('exe')), 'data') - if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) { - app.setPath('userData', dir) - } - } -} diff --git a/src/preload/index.ts b/src/preload/index.ts index 321071c248..2a8ac3df89 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -26,6 +26,12 @@ const api = { handleZoomFactor: (delta: number, reset: boolean = false) => ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset), setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive), + select: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.App_Select, options), + hasWritePermission: (path: string) => ipcRenderer.invoke(IpcChannel.App_HasWritePermission, path), + setAppDataPath: (path: string) => ipcRenderer.invoke(IpcChannel.App_SetAppDataPath, path), + copy: (oldPath: string, newPath: string) => ipcRenderer.invoke(IpcChannel.App_Copy, oldPath, newPath), + setStopQuitApp: (stop: boolean, reason: string) => ipcRenderer.invoke(IpcChannel.App_SetStopQuitApp, stop, reason), + relaunchApp: () => ipcRenderer.invoke(IpcChannel.App_RelaunchApp), openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url), getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize), clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache), diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index b31d295b3a..79218a6cb4 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1084,6 +1084,24 @@ "assistant.title": "Default Assistant", "data": { "app_data": "App Data", + "app_data.select": "Modify Directory", + "app_data.select_title": "Change App Data Directory", + "app_data.restart_notice": "The app will need to restart to apply the changes", + "app_data.copy_data_option": "Copy data from original directory to new directory", + "app_data.copy_time_notice": "Copying data may take a while, do not force quit app", + "app_data.path_changed_without_copy": "Path changed successfully, but data not copied", + "app_data.copying_warning": "Data copying, do not force quit app", + "app_data.copying": "Copying data to new location...", + "app_data.copy_success": "Successfully copied data to new location", + "app_data.copy_failed": "Failed to copy data", + "app_data.select_success": "Data directory changed, the app will restart to apply changes", + "app_data.select_error": "Failed to change data directory", + "app_data.migration_title": "Data Migration", + "app_data.original_path": "Original Path", + "app_data.new_path": "New Path", + "app_data.select_error_root_path": "New path cannot be the root path", + "app_data.select_error_write_permission": "New path does not have write permission", + "app_data.stop_quit_app_reason": "The app is currently migrating data and cannot be exited", "app_knowledge": "Knowledge Base Files", "app_knowledge.button.delete": "Delete File", "app_knowledge.remove_all": "Remove Knowledge Base Files", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 87953447e7..3addc9ceae 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1081,7 +1081,25 @@ "assistant.title": "デフォルトアシスタント", "data": { "app_data": "アプリデータ", - "app_knowledge": "ナレッジベースファイル", + "app_data.select": "ディレクトリを変更", + "app_data.select_title": "アプリデータディレクトリの変更", + "app_data.restart_notice": "変更を適用するには、アプリを再起動する必要があります", + "app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます", + "app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください", + "app_data.path_changed_without_copy": "パスが変更されましたが、データがコピーされていません", + "app_data.copying_warning": "データコピー中、アプリを強制終了しないでください", + "app_data.copying": "新しい場所にデータをコピーしています...", + "app_data.copy_success": "データを新しい場所に正常にコピーしました", + "app_data.copy_failed": "データのコピーに失敗しました", + "app_data.select_success": "データディレクトリが変更されました。変更を適用するためにアプリが再起動します", + "app_data.select_error": "データディレクトリの変更に失敗しました", + "app_data.migration_title": "データ移行", + "app_data.original_path": "元のパス", + "app_data.new_path": "新しいパス", + "app_data.select_error_root_path": "新しいパスはルートパスにできません", + "app_data.select_error_write_permission": "新しいパスに書き込み権限がありません", + "app_data.stop_quit_app_reason": "アプリは現在データを移行しているため、終了できません", + "app_knowledge": "知識ベースファイル", "app_knowledge.button.delete": "ファイルを削除", "app_knowledge.remove_all": "ナレッジベースファイルを削除", "app_knowledge.remove_all_confirm": "ナレッジベースファイルを削除すると、ナレッジベース自体は削除されません。これにより、ストレージ容量を節約できます。続行しますか?", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index f9beb63310..a73c1c986d 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1092,7 +1092,25 @@ "assistant.title": "Ассистент по умолчанию", "data": { "app_data": "Данные приложения", - "app_knowledge": "База знаний", + "app_data.select": "Изменить директорию", + "app_data.select_title": "Изменить директорию данных приложения", + "app_data.restart_notice": "Для применения изменений потребуется перезапуск приложения", + "app_data.copy_data_option": "Копировать данные из исходной директории в новую директорию", + "app_data.copy_time_notice": "Копирование данных из исходной директории займет некоторое время, пожалуйста, будьте терпеливы", + "app_data.path_changed_without_copy": "Путь изменен успешно, но данные не скопированы", + "app_data.copying_warning": "Копирование данных, нельзя взаимодействовать с приложением, не закрывайте приложение", + "app_data.copying": "Копирование данных в новое место...", + "app_data.copy_success": "Данные успешно скопированы в новое место", + "app_data.copy_failed": "Не удалось скопировать данные", + "app_data.select_success": "Директория данных изменена, приложение будет перезапущено для применения изменений", + "app_data.select_error": "Не удалось изменить директорию данных", + "app_data.migration_title": "Миграция данных", + "app_data.original_path": "Исходный путь", + "app_data.new_path": "Новый путь", + "app_data.select_error_root_path": "Новый путь не может быть корневым", + "app_data.select_error_write_permission": "Новый путь не имеет разрешения на запись", + "app_data.stop_quit_app_reason": "Приложение в настоящее время перемещает данные и не может быть закрыто", + "app_knowledge": "Файлы базы знаний", "app_knowledge.button.delete": "Удалить файл", "app_knowledge.remove_all": "Удалить файлы базы знаний", "app_knowledge.remove_all_confirm": "Удаление файлов базы знаний не удалит саму базу знаний, что позволит уменьшить занимаемый объем памяти, продолжить?", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 98ee5763d4..e0fd0c6baa 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1084,6 +1084,24 @@ "assistant.title": "默认助手", "data": { "app_data": "应用数据", + "app_data.select": "修改目录", + "app_data.select_title": "更改应用数据目录", + "app_data.restart_notice": "应用需要重启以应用更改", + "app_data.copy_data_option": "复制数据,开启后会将原始目录数据复制到新目录", + "app_data.copy_time_notice": "复制数据将需要一些时间,复制期间不要关闭应用", + "app_data.path_changed_without_copy": "路径已更改成功,但数据未复制", + "app_data.copying_warning": "数据复制中,不要强制退出app", + "app_data.copying": "正在将数据复制到新位置...", + "app_data.copy_success": "已成功复制数据到新位置", + "app_data.copy_failed": "复制数据失败", + "app_data.select_success": "数据目录已更改,应用将重启以应用更改", + "app_data.select_error": "更改数据目录失败", + "app_data.migration_title": "数据迁移", + "app_data.original_path": "原始路径", + "app_data.new_path": "新路径", + "app_data.select_error_root_path": "新路径不能是根路径", + "app_data.select_error_write_permission": "新路径没有写入权限", + "app_data.stop_quit_app_reason": "应用目前在迁移数据, 不能退出", "app_knowledge": "知识库文件", "app_knowledge.button.delete": "删除文件", "app_knowledge.remove_all": "删除知识库文件", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 51d236c2da..a07610aa29 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1082,7 +1082,25 @@ "assistant.icon.type.none": "不顯示", "assistant.title": "預設助手", "data": { - "app_data": "應用程式資料", + "app_data": "應用數據", + "app_data.select": "修改目錄", + "app_data.select_title": "變更應用數據目錄", + "app_data.restart_notice": "變更數據目錄後需要重啟應用才能生效", + "app_data.copy_data_option": "複製數據, 開啟後會將原始目錄數據複製到新目錄", + "app_data.copy_time_notice": "複製數據將需要一些時間,複製期間不要關閉應用", + "app_data.path_changed_without_copy": "路徑已變更成功,但數據未複製", + "app_data.copying_warning": "數據複製中,不要強制退出應用", + "app_data.copying": "正在複製數據到新位置...", + "app_data.copy_success": "成功複製數據到新位置", + "app_data.copy_failed": "複製數據失敗", + "app_data.select_success": "數據目錄已變更,應用將重啟以應用變更", + "app_data.select_error": "變更數據目錄失敗", + "app_data.migration_title": "數據遷移", + "app_data.original_path": "原始路徑", + "app_data.new_path": "新路徑", + "app_data.select_error_root_path": "新路徑不能是根路徑", + "app_data.select_error_write_permission": "新路徑沒有寫入權限", + "app_data.stop_quit_app_reason": "應用目前正在遷移數據,不能退出", "app_knowledge": "知識庫文件", "app_knowledge.button.delete": "刪除檔案", "app_knowledge.remove_all": "刪除知識庫檔案", diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 90e25de51a..5a80545345 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -2,6 +2,7 @@ import { CloudSyncOutlined, FileSearchOutlined, FolderOpenOutlined, + LoadingOutlined, SaveOutlined, YuqueOutlined } from '@ant-design/icons' @@ -18,7 +19,7 @@ import store, { useAppDispatch } from '@renderer/store' import { setSkipBackupFile as _setSkipBackupFile } from '@renderer/store/settings' import { AppInfo } from '@renderer/types' import { formatFileSize } from '@renderer/utils' -import { Button, Switch, Typography } from 'antd' +import { Button, Progress, Switch, Typography } from 'antd' import { FileText, FolderCog, FolderInput, Sparkle } from 'lucide-react' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -179,6 +180,281 @@ const DataSettings: FC = () => { }) } + const handleSelectAppDataPath = async () => { + if (!appInfo || !appInfo.appDataPath) { + return + } + + const newAppDataPath = await window.api.select({ + properties: ['openDirectory', 'createDirectory'], + title: t('settings.data.app_data.select_title') + }) + + if (!newAppDataPath) { + return + } + + // check new app data path is root path + // if is root path, show error + const pathParts = newAppDataPath.split(/[/\\]/).filter((part: string) => part !== '') + if (pathParts.length <= 1) { + window.message.error(t('settings.data.app_data.select_error_root_path')) + return + } + + // check new app data path has write permission + const hasWritePermission = await window.api.hasWritePermission(newAppDataPath) + if (!hasWritePermission) { + window.message.error(t('settings.data.app_data.select_error_write_permission')) + return + } + + const migrationTitle = ( +
{t('settings.data.app_data.migration_title')}
+ ) + const migrationClassName = 'migration-modal' + const messageKey = 'data-migration' + + // 显示确认对话框 + showMigrationConfirmModal(appInfo.appDataPath, newAppDataPath, migrationTitle, migrationClassName, messageKey) + } + + // 显示确认迁移的对话框 + const showMigrationConfirmModal = ( + originalPath: string, + newPath: string, + title: React.ReactNode, + className: string, + messageKey: string + ) => { + // 复制数据选项状态 + let shouldCopyData = true + + // 创建路径内容组件 + const PathsContent = () => ( +
+ + {t('settings.data.app_data.original_path')}: + {originalPath} + + + {t('settings.data.app_data.new_path')}: + {newPath} + +
+ ) + + const CopyDataContent = () => ( +
+ + { + shouldCopyData = checked + }} + style={{ marginRight: '8px' }} + /> + + {t('settings.data.app_data.copy_data_option')} + + +
+ ) + + // 显示确认模态框 + const modal = window.modal.confirm({ + title, + className, + width: 'min(600px, 90vw)', + style: { minHeight: '400px' }, + content: ( + + + + +

{t('settings.data.app_data.restart_notice')}

+

+ {t('settings.data.app_data.copy_time_notice')} +

+
+
+ ), + centered: true, + okButtonProps: { + danger: true + }, + okText: t('common.confirm'), + cancelText: t('common.cancel'), + onOk: async () => { + try { + // 立即关闭确认对话框 + modal.destroy() + + // 设置停止退出应用 + window.api.setStopQuitApp(true, t('settings.data.app_data.stop_quit_app_reason')) + + if (shouldCopyData) { + // 如果选择复制数据,显示进度模态框并执行迁移 + const { loadingModal, progressInterval, updateProgress } = showProgressModal(title, className, PathsContent) + + try { + await startMigration(originalPath, newPath, progressInterval, updateProgress, loadingModal, messageKey) + } catch (error) { + if (progressInterval) { + clearInterval(progressInterval) + } + loadingModal.destroy() + throw error + } + } else { + // 如果不复制数据,直接设置新的应用数据路径 + await window.api.setAppDataPath(newPath) + window.message.success(t('settings.data.app_data.path_changed_without_copy')) + } + + // 更新应用数据路径 + setAppInfo(await window.api.getAppInfo()) + + // 通知用户并重启应用 + setTimeout(() => { + window.message.success(t('settings.data.app_data.select_success')) + window.api.setStopQuitApp(false, '') + window.api.relaunchApp() + }, 1000) + } catch (error) { + window.api.setStopQuitApp(false, '') + window.message.error({ + content: + (shouldCopyData + ? t('settings.data.app_data.copy_failed') + : t('settings.data.app_data.path_change_failed')) + + ': ' + + error, + duration: 5 + }) + } + } + }) + } + + // 显示进度模态框 + const showProgressModal = (title: React.ReactNode, className: string, PathsContent: React.FC) => { + let currentProgress = 0 + let progressInterval: NodeJS.Timeout | null = null + + // 创建进度更新模态框 + const loadingModal = window.modal.info({ + title, + className, + width: 'min(600px, 90vw)', + style: { minHeight: '400px' }, + icon: , + content: ( + + + +

{t('settings.data.app_data.copying')}

+
+ +
+

+ {t('settings.data.app_data.copying_warning')} +

+
+
+ ), + centered: true, + closable: false, + maskClosable: false, + okButtonProps: { style: { display: 'none' } } + }) + + // 更新进度的函数 + const updateProgress = (progress: number, status: 'active' | 'success' = 'active') => { + loadingModal.update({ + title, + content: ( + + + +

{t('settings.data.app_data.copying')}

+
+ +
+

+ {t('settings.data.app_data.copying_warning')} +

+
+
+ ) + }) + } + + // 开始模拟进度更新 + progressInterval = setInterval(() => { + if (currentProgress < 95) { + currentProgress += Math.random() * 5 + 1 + if (currentProgress > 95) currentProgress = 95 + updateProgress(currentProgress) + } + }, 500) + + return { loadingModal, progressInterval, updateProgress } + } + + // 开始迁移数据 + const startMigration = async ( + originalPath: string, + newPath: string, + progressInterval: NodeJS.Timeout | null, + updateProgress: (progress: number, status?: 'active' | 'success') => void, + loadingModal: { destroy: () => void }, + messageKey: string + ): Promise => { + // 开始复制过程 + const copyResult = await window.api.copy(originalPath, newPath) + + // 停止进度更新 + if (progressInterval) { + clearInterval(progressInterval) + } + + // 显示100%完成 + updateProgress(100, 'success') + + if (!copyResult.success) { + // 延迟关闭加载模态框 + await new Promise((resolve) => { + setTimeout(() => { + loadingModal.destroy() + window.message.error({ + content: t('settings.data.app_data.copy_failed') + ': ' + copyResult.error, + key: messageKey, + duration: 5 + }) + resolve() + }, 500) + }) + + throw new Error(copyResult.error || 'Unknown error during copy') + } + + // 在复制成功后设置新的AppDataPath + await window.api.setAppDataPath(newPath) + + // 短暂延迟以显示100%完成 + await new Promise((resolve) => setTimeout(resolve, 500)) + + // 关闭加载模态框 + loadingModal.destroy() + + window.message.success({ + content: t('settings.data.app_data.copy_success'), + key: messageKey, + duration: 2 + }) + } + const onSkipBackupFilesChange = (value: boolean) => { setSkipBackupFile(value) dispatch(_setSkipBackupFile(value)) @@ -245,6 +521,9 @@ const DataSettings: FC = () => { {appInfo?.appDataPath} handleOpenPath(appInfo?.appDataPath)} style={{ flexShrink: 0 }} /> + + + @@ -352,4 +631,38 @@ const PathRow = styled(HStack)` gap: 5px; ` +// Add styled components for migration modal +const MigrationModalContent = styled.div` + padding: 20px 0 10px; + display: flex; + flex-direction: column; +` + +const MigrationNotice = styled.div` + margin-top: 24px; + font-size: 14px; +` + +const MigrationPathRow = styled.div` + display: flex; + flex-direction: column; + gap: 5px; +` + +const MigrationPathLabel = styled.div` + font-weight: 600; + font-size: 15px; + color: var(--color-text-1); +` + +const MigrationPathValue = styled.div` + font-size: 14px; + color: var(--color-text-2); + background-color: var(--color-background-soft); + padding: 8px 12px; + border-radius: 4px; + word-break: break-all; + border: 1px solid var(--color-border); +` + export default DataSettings