feat(storage): 支持自定义数据目录与显式迁移

Fixes #242
This commit is contained in:
Syngnat
2026-04-11 21:53:50 +08:00
parent c810d999bd
commit 1f617f9d53
14 changed files with 656 additions and 19 deletions

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined } from '@ant-design/icons';
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined } from '@ant-design/icons';
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
import Sidebar from './components/Sidebar';
import TabManager from './components/TabManager';
@@ -38,7 +38,7 @@ import {
resolveAIEdgeHandleDockStyle,
resolveAIEdgeHandleStyle,
} from './utils/aiEntryLayout';
import { SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
import { ApplyDataRootDirectory, GetDataRootDirectoryInfo, OpenDataRootDirectory, SelectDataRootDirectory, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
import './App.css';
const { Sider, Content } = Layout;
@@ -1411,6 +1411,11 @@ function App() {
const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false);
const [capturingShortcutAction, setCapturingShortcutAction] = useState<ShortcutAction | null>(null);
const [isProxyModalOpen, setIsProxyModalOpen] = useState(false);
const [isDataRootModalOpen, setIsDataRootModalOpen] = useState(false);
const [dataRootInfo, setDataRootInfo] = useState<any>(null);
const [selectedDataRootPath, setSelectedDataRootPath] = useState('');
const [dataRootLoading, setDataRootLoading] = useState(false);
const [dataRootApplying, setDataRootApplying] = useState(false);
const [isAISettingsOpen, setIsAISettingsOpen] = useState(false);
const aiEntryPlacement = resolveAIEntryPlacement();
const aiEdgeHandleAttachment = resolveAIEdgeHandleAttachment(aiPanelVisible);
@@ -1468,6 +1473,84 @@ function App() {
</Tooltip>
);
const loadDataRootInfo = useCallback(async () => {
setDataRootLoading(true);
try {
const res = await GetDataRootDirectoryInfo();
if (!res?.success) {
throw new Error(res?.message || '加载数据目录信息失败');
}
const data = (res?.data || {}) as any;
setDataRootInfo(data);
setSelectedDataRootPath(String(data.path || ''));
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error || '未知错误');
void message.error(`加载数据目录信息失败: ${errMsg}`);
} finally {
setDataRootLoading(false);
}
}, []);
useEffect(() => {
if (!isDataRootModalOpen) {
return;
}
void loadDataRootInfo();
}, [isDataRootModalOpen, loadDataRootInfo]);
const handleSelectDataRoot = useCallback(async () => {
try {
const res = await SelectDataRootDirectory(selectedDataRootPath || dataRootInfo?.path || '');
if (!res?.success) {
if (String(res?.message || '') !== '已取消') {
throw new Error(res?.message || '选择数据目录失败');
}
return;
}
const data = (res?.data || {}) as any;
setSelectedDataRootPath(String(data.path || ''));
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error || '未知错误');
void message.error(`选择数据目录失败: ${errMsg}`);
}
}, [dataRootInfo?.path, selectedDataRootPath]);
const handleApplyDataRoot = useCallback(async (migrate: boolean, useDefaultPath = false) => {
const nextPath = useDefaultPath ? String(dataRootInfo?.defaultPath || '') : String(selectedDataRootPath || '').trim();
if (!nextPath) {
void message.warning('请先选择有效的数据目录');
return;
}
setDataRootApplying(true);
try {
const res = await ApplyDataRootDirectory(nextPath, migrate);
if (!res?.success) {
throw new Error(res?.message || '应用数据目录失败');
}
const data = (res?.data || {}) as any;
setDataRootInfo(data);
setSelectedDataRootPath(String(data.path || nextPath));
void message.success(res?.message || '数据目录已更新');
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error || '未知错误');
void message.error(`应用数据目录失败: ${errMsg}`);
} finally {
setDataRootApplying(false);
}
}, [dataRootInfo?.defaultPath, selectedDataRootPath]);
const handleOpenDataRoot = useCallback(async () => {
try {
const res = await OpenDataRootDirectory();
if (!res?.success) {
throw new Error(res?.message || '打开数据目录失败');
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error || '未知错误');
void message.error(`打开数据目录失败: ${errMsg}`);
}
}, []);
// Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制
const LOG_PANEL_TOOLBAR_HEIGHT = 32;
@@ -2179,6 +2262,16 @@ function App() {
setIsDriverModalOpen(true);
},
},
{
key: 'data-root',
icon: <HddOutlined />,
title: '数据目录',
description: '查看、切换或迁移本地数据存储位置。',
onClick: () => {
setIsToolsModalOpen(false);
setIsDataRootModalOpen(true);
},
},
{
key: 'shortcut-settings',
icon: <LinkOutlined />,
@@ -2202,6 +2295,74 @@ function App() {
))}
</div>
</Modal>
<Modal
title={renderUtilityModalTitle(<HddOutlined />, '数据存储位置', '统一管理连接、代理、AI 配置与驱动等文件型数据的根目录。')}
open={isDataRootModalOpen}
onCancel={() => setIsDataRootModalOpen(false)}
footer={null}
width={720}
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
>
{dataRootLoading ? (
<div style={{ padding: '16px 0', textAlign: 'center' }}>
<Spin />
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '12px 0' }}>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 600 }}></div>
<div style={{ display: 'grid', gap: 10 }}>
<Input readOnly value={dataRootInfo?.path || ''} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<div style={{ marginBottom: 6, fontWeight: 500 }}></div>
<div style={utilityMutedTextStyle}>{dataRootInfo?.defaultPath || '-'}</div>
</div>
<div>
<div style={{ marginBottom: 6, fontWeight: 500 }}></div>
<div style={utilityMutedTextStyle}>{dataRootInfo?.driverPath || '-'}</div>
</div>
</div>
</div>
</div>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 600 }}></div>
<div style={{ display: 'grid', gap: 10 }}>
<Input
readOnly
value={selectedDataRootPath}
placeholder="选择新的数据目录"
/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
<Button icon={<FolderOpenOutlined />} onClick={() => void handleSelectDataRoot()}>
</Button>
<Button onClick={() => void handleOpenDataRoot()}>
</Button>
<Button loading={dataRootApplying} onClick={() => void handleApplyDataRoot(false, true)}>
</Button>
</div>
</div>
</div>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 600 }}></div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
<Button loading={dataRootApplying} onClick={() => void handleApplyDataRoot(false)}>
</Button>
<Button type="primary" loading={dataRootApplying} onClick={() => void handleApplyDataRoot(true)}>
</Button>
</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 10 }}>
AI secret store
</div>
</div>
</div>
)}
</Modal>
<DataSyncModal
open={isSyncModalOpen}
onClose={() => setIsSyncModalOpen(false)}

View File

@@ -21,6 +21,13 @@ loader.config({ monaco })
if (typeof window !== 'undefined' && !(window as any).go) {
const mockConnections: any[] = [];
let mockGlobalProxy: any = { enabled: false, type: 'socks5', host: '', port: 1080, user: '', password: '', hasPassword: false };
let mockDataRootInfo: any = {
path: 'C:/mock/.gonavi',
defaultPath: 'C:/mock/.gonavi',
driverPath: 'C:/mock/.gonavi/drivers',
isDefaultPath: true,
bootstrapPath: 'C:/mock/.gonavi/storage_root.json',
};
const upsertMockConnection = (view: any) => {
const index = mockConnections.findIndex((item) => item.id === view.id);
@@ -118,14 +125,27 @@ if (typeof window !== 'undefined' && !(window as any).go) {
SaveQuery: async () => null,
DeleteQuery: async () => null,
GetAppInfo: async () => ({}),
GetDataRootDirectoryInfo: async () => ({ success: true, data: cloneBrowserMockValue(mockDataRootInfo) }),
CheckForUpdates: async () => ({ success: false }),
OpenDownloadedUpdateDirectory: async () => ({ success: false }),
OpenDataRootDirectory: async () => ({ success: true }),
InstallUpdateAndRestart: async () => ({ success: false }),
ImportConfigFile: async () => ({ success: false }),
ExportData: async () => ({ success: false }),
GetGlobalProxyConfig: async () => ({ success: true, data: cloneBrowserMockValue(mockGlobalProxy) }),
SaveGlobalProxy: async (input: any) => saveMockGlobalProxy(input),
ImportLegacyGlobalProxy: async (input: any) => saveMockGlobalProxy(input),
SelectDataRootDirectory: async (currentPath: string) => ({ success: true, data: { ...mockDataRootInfo, path: currentPath || mockDataRootInfo.path } }),
ApplyDataRootDirectory: async (path: string) => {
const nextPath = String(path || mockDataRootInfo.defaultPath);
mockDataRootInfo = {
...mockDataRootInfo,
path: nextPath,
driverPath: `${nextPath}/drivers`,
isDefaultPath: nextPath === mockDataRootInfo.defaultPath,
};
return { success: true, message: '数据目录已更新', data: cloneBrowserMockValue(mockDataRootInfo) };
},
}
}
};