mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-01 12:10:05 +08:00
✨ feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路
- 统一 DuckDB 文件库与 Parquet 文件接入能力 - 补充 URI、文件选择、只读挂载与连接缓存键处理 - 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿 - refs #166
This commit is contained in:
@@ -3,7 +3,6 @@ import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Se
|
||||
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { looksLikeDuckDBParquetPath, resolveDuckDBMode } from '../utils/duckdb';
|
||||
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
|
||||
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
|
||||
|
||||
@@ -119,9 +118,6 @@ const ConnectionModal: React.FC<{
|
||||
const [driverStatusLoaded, setDriverStatusLoaded] = useState(false);
|
||||
const [selectingDbFile, setSelectingDbFile] = useState(false);
|
||||
const [selectingSSHKey, setSelectingSSHKey] = useState(false);
|
||||
const watchedDuckDBMode = Form.useWatch('duckdbMode', form);
|
||||
const isDuckDBParquetMode = dbType === 'duckdb'
|
||||
&& resolveDuckDBMode(watchedDuckDBMode, String(form.getFieldValue('host') || '')) === 'parquet';
|
||||
const testInFlightRef = useRef(false);
|
||||
const testTimerRef = useRef<number | null>(null);
|
||||
const addConnection = useStore((state) => state.addConnection);
|
||||
@@ -249,17 +245,15 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const resolveDriverUnavailableReason = async (type: string, options?: { allowFetch?: boolean }): Promise<string> => {
|
||||
const resolveDriverUnavailableReason = async (type: string): Promise<string> => {
|
||||
const normalized = normalizeDriverType(type);
|
||||
if (!normalized || normalized === 'custom') {
|
||||
return '';
|
||||
}
|
||||
const allowFetch = options?.allowFetch !== false;
|
||||
let snapshot = driverStatusMap;
|
||||
if (!snapshot[normalized] && allowFetch) {
|
||||
if (!snapshot[normalized]) {
|
||||
snapshot = await fetchDriverStatusMap();
|
||||
setDriverStatusMap(snapshot);
|
||||
setDriverStatusLoaded(true);
|
||||
}
|
||||
const status = snapshot[normalized];
|
||||
if (!status || status.connectable) {
|
||||
@@ -539,25 +533,14 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
|
||||
if (isFileDatabaseType(type)) {
|
||||
let rawPath = trimmedUri
|
||||
const rawPath = trimmedUri
|
||||
.replace(/^sqlite:\/\//i, '')
|
||||
.replace(/^duckdb:\/\//i, '')
|
||||
.trim();
|
||||
let duckdbMode = 'database';
|
||||
if (type === 'duckdb') {
|
||||
const queryIndex = rawPath.indexOf('?');
|
||||
const searchText = queryIndex >= 0 ? rawPath.slice(queryIndex + 1) : '';
|
||||
if (queryIndex >= 0) {
|
||||
rawPath = rawPath.slice(0, queryIndex).trim();
|
||||
}
|
||||
duckdbMode = resolveDuckDBMode(new URLSearchParams(searchText).get('mode'), safeDecode(rawPath));
|
||||
}
|
||||
if (!rawPath) {
|
||||
return null;
|
||||
}
|
||||
return type === 'duckdb'
|
||||
? { host: normalizeFileDbPath(safeDecode(rawPath)), duckdbMode }
|
||||
: { host: normalizeFileDbPath(safeDecode(rawPath)) };
|
||||
return { host: normalizeFileDbPath(safeDecode(rawPath)) };
|
||||
}
|
||||
|
||||
if (type === 'redis') {
|
||||
@@ -770,9 +753,7 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
if (isFileDatabaseType(dbType)) {
|
||||
return dbType === 'duckdb'
|
||||
? (isDuckDBParquetMode
|
||||
? 'duckdb:///Users/name/demo.parquet?mode=parquet'
|
||||
: 'duckdb:///Users/name/demo.duckdb')
|
||||
? 'duckdb:///Users/name/demo.duckdb'
|
||||
: 'sqlite:///Users/name/demo.sqlite';
|
||||
}
|
||||
if (dbType === 'mongodb') {
|
||||
@@ -858,20 +839,12 @@ const ConnectionModal: React.FC<{
|
||||
const scheme = values.useSSL ? 'rediss' : 'redis';
|
||||
return `${scheme}://${redisAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
if (isFileDatabaseType(type)) {
|
||||
const pathText = normalizeFileDbPath(String(values.host || '').trim());
|
||||
if (!pathText) {
|
||||
return `${type}://`;
|
||||
}
|
||||
if (type === 'duckdb') {
|
||||
const params = new URLSearchParams();
|
||||
const duckdbMode = resolveDuckDBMode(values.duckdbMode, pathText);
|
||||
if (duckdbMode === 'parquet') {
|
||||
params.set('mode', 'parquet');
|
||||
}
|
||||
const query = params.toString();
|
||||
return `${type}://${encodeURI(pathText)}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
return `${type}://${encodeURI(pathText)}`;
|
||||
}
|
||||
|
||||
@@ -1054,15 +1027,7 @@ const ConnectionModal: React.FC<{
|
||||
const data = res.data || {};
|
||||
const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim();
|
||||
if (selectedPath) {
|
||||
const normalizedPath = normalizeFileDbPath(selectedPath);
|
||||
if (dbType === 'duckdb') {
|
||||
form.setFieldsValue({
|
||||
host: normalizedPath,
|
||||
duckdbMode: looksLikeDuckDBParquetPath(normalizedPath) ? 'parquet' : 'database',
|
||||
});
|
||||
} else {
|
||||
form.setFieldValue('host', normalizedPath);
|
||||
}
|
||||
form.setFieldValue('host', normalizeFileDbPath(selectedPath));
|
||||
}
|
||||
} else if (res?.message !== 'Cancelled') {
|
||||
message.error(`选择数据库文件失败: ${res?.message || '未知错误'}`);
|
||||
@@ -1121,7 +1086,6 @@ const ConnectionModal: React.FC<{
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
duckdbMode: configType === 'duckdb' ? resolveDuckDBMode((config as any).duckdbMode, primaryHost) : 'database',
|
||||
uri: config.uri || '',
|
||||
includeDatabases: initialValues.includeDatabases,
|
||||
includeRedisDatabases: initialValues.includeRedisDatabases,
|
||||
@@ -1230,7 +1194,7 @@ const ConnectionModal: React.FC<{
|
||||
const isRedisType = values.type === 'redis';
|
||||
const newConn = {
|
||||
id: initialValues ? initialValues.id : Date.now().toString(),
|
||||
name: values.name || (isFileDatabaseType(values.type) ? (values.type === 'duckdb' ? (resolveDuckDBMode(values.duckdbMode, String(values.host || '')) === 'parquet' ? 'DuckDB Parquet' : 'DuckDB DB') : 'SQLite DB') : (values.type === 'redis' ? 'Redis ' + displayHost : displayHost)),
|
||||
name: values.name || (isFileDatabaseType(values.type) ? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB') : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)),
|
||||
config: config,
|
||||
includeDatabases: values.includeDatabases,
|
||||
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined
|
||||
@@ -1550,7 +1514,6 @@ const ConnectionModal: React.FC<{
|
||||
password: keepPassword ? (mergedValues.password || "") : "",
|
||||
savePassword: savePassword,
|
||||
database: mergedValues.database || "",
|
||||
duckdbMode: type === 'duckdb' ? resolveDuckDBMode(mergedValues.duckdbMode, primaryHost) : undefined,
|
||||
useSSL: effectiveUseSSL,
|
||||
sslMode: effectiveUseSSL ? sslMode : 'disable',
|
||||
sslCertPath: sslCertPath,
|
||||
@@ -1581,8 +1544,9 @@ const ConnectionModal: React.FC<{
|
||||
mongoReplicaPassword: keepPassword ? mongoReplicaPassword : "",
|
||||
};
|
||||
};
|
||||
|
||||
const handleTypeSelect = async (type: string) => {
|
||||
const unavailableReason = await resolveDriverUnavailableReason(type, { allowFetch: false });
|
||||
const unavailableReason = await resolveDriverUnavailableReason(type);
|
||||
if (unavailableReason) {
|
||||
const normalized = normalizeDriverType(type);
|
||||
const driverName = driverStatusMap[normalized]?.name || type;
|
||||
@@ -1592,9 +1556,6 @@ const ConnectionModal: React.FC<{
|
||||
setTypeSelectWarning(null);
|
||||
setDbType(type);
|
||||
form.setFieldsValue({ type: type });
|
||||
if (!driverStatusLoaded) {
|
||||
void refreshDriverStatus();
|
||||
}
|
||||
|
||||
const defaultPort = getDefaultPortByType(type);
|
||||
if (isFileDatabaseType(type)) {
|
||||
@@ -1608,7 +1569,6 @@ const ConnectionModal: React.FC<{
|
||||
user: '',
|
||||
password: '',
|
||||
database: '',
|
||||
duckdbMode: type === 'duckdb' ? 'database' : undefined,
|
||||
useSSL: false,
|
||||
sslMode: 'preferred',
|
||||
sslCertPath: '',
|
||||
@@ -1851,29 +1811,15 @@ const ConnectionModal: React.FC<{
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{dbType === 'duckdb' && (
|
||||
<Form.Item
|
||||
name="duckdbMode"
|
||||
label="文件模式"
|
||||
help={isDuckDBParquetMode ? 'Parquet 会以只读视图挂载到 DuckDB,适合浏览与查询。' : '数据库文件模式保持 DuckDB 原生文件库行为。'}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'database', label: '数据库文件' },
|
||||
{ value: 'parquet', label: 'Parquet 文件' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isFileDb ? 'minmax(0, 1fr) 120px' : 'minmax(0, 1fr) 120px', gap: 16, alignItems: 'start' }}>
|
||||
<Form.Item
|
||||
name="host"
|
||||
label={isFileDb ? (isDuckDBParquetMode ? 'Parquet 文件路径 (绝对路径)' : '文件路径 (绝对路径)') : '主机地址 (Host)'}
|
||||
label={isFileDb ? '文件路径 (绝对路径)' : '主机地址 (Host)'}
|
||||
rules={[createUriAwareRequiredRule('请输入地址/路径')]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
placeholder={isFileDb ? (dbType === 'duckdb' ? (isDuckDBParquetMode ? '/path/to/data.parquet' : '/path/to/db.duckdb') : '/path/to/db.sqlite') : 'localhost'}
|
||||
placeholder={isFileDb ? (dbType === 'duckdb' ? '/path/to/db.duckdb' : '/path/to/db.sqlite') : 'localhost'}
|
||||
onDoubleClick={requestTest}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -2384,7 +2330,6 @@ const ConnectionModal: React.FC<{
|
||||
httpTunnelPort: 8080,
|
||||
timeout: 30,
|
||||
uri: '',
|
||||
duckdbMode: 'database',
|
||||
mysqlTopology: 'single',
|
||||
redisTopology: 'single',
|
||||
mongoTopology: 'single',
|
||||
@@ -2406,7 +2351,7 @@ const ConnectionModal: React.FC<{
|
||||
setTestResult(null);
|
||||
setTestErrorLogOpen(false);
|
||||
}
|
||||
if (changed.uri !== undefined || changed.type !== undefined || changed.duckdbMode !== undefined) {
|
||||
if (changed.uri !== undefined || changed.type !== undefined) {
|
||||
setUriFeedback(null);
|
||||
}
|
||||
if (changed.useSSL !== undefined) {
|
||||
|
||||
@@ -36,15 +36,9 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
|
||||
import { SavedConnection } from '../types';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
const isForceReadOnlyNode = (node: any): boolean => {
|
||||
const config = node?.dataRef?.config || node?.dataRef;
|
||||
return getDataSourceCapabilities(config as any).forceReadOnlyQueryResult;
|
||||
};
|
||||
|
||||
interface TreeNode {
|
||||
title: string;
|
||||
key: string;
|
||||
@@ -3160,41 +3154,14 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
}
|
||||
},
|
||||
];
|
||||
{
|
||||
key: 'run-sql',
|
||||
label: '运行 SQL 文件...',
|
||||
icon: <FileAddOutlined />,
|
||||
onClick: () => handleRunSQLFile(node)
|
||||
}
|
||||
];
|
||||
} else if (node.type === 'view') {
|
||||
const forceReadOnlyNode = isForceReadOnlyNode(node);
|
||||
if (forceReadOnlyNode) {
|
||||
return [
|
||||
{
|
||||
key: 'open-view',
|
||||
label: '浏览视图数据',
|
||||
icon: <EyeOutlined />,
|
||||
onClick: () => onDoubleClick(null, node)
|
||||
},
|
||||
{
|
||||
key: 'view-definition',
|
||||
label: '查看视图定义',
|
||||
icon: <CodeOutlined />,
|
||||
onClick: () => openViewDefinition(node)
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'new-query',
|
||||
label: '新建查询',
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => {
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: `新建查询`,
|
||||
type: 'query',
|
||||
connectionId: node.dataRef.id,
|
||||
dbName: node.dataRef.dbName,
|
||||
query: ""
|
||||
});
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: 'open-view',
|
||||
@@ -3226,7 +3193,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
type: 'query',
|
||||
connectionId: node.dataRef.id,
|
||||
dbName: node.dataRef.dbName,
|
||||
query: ""
|
||||
query: ''
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -3275,45 +3242,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
},
|
||||
];
|
||||
} else if (node.type === 'table') {
|
||||
const forceReadOnlyNode = isForceReadOnlyNode(node);
|
||||
if (forceReadOnlyNode) {
|
||||
return [
|
||||
{
|
||||
key: 'open-table',
|
||||
label: '浏览数据',
|
||||
icon: <EyeOutlined />,
|
||||
onClick: () => onDoubleClick(null, node)
|
||||
},
|
||||
{
|
||||
key: 'new-query',
|
||||
label: '新建查询',
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => {
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: `新建查询`,
|
||||
type: 'query',
|
||||
connectionId: node.dataRef.id,
|
||||
dbName: node.dataRef.dbName,
|
||||
query: ""
|
||||
});
|
||||
}
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'export',
|
||||
label: '导出表数据',
|
||||
icon: <ExportOutlined />,
|
||||
children: [
|
||||
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(node, 'csv') },
|
||||
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(node, 'xlsx') },
|
||||
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(node, 'json') },
|
||||
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(node, 'md') },
|
||||
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(node, 'html') },
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: 'new-query',
|
||||
@@ -3326,7 +3254,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
type: 'query',
|
||||
connectionId: node.dataRef.id,
|
||||
dbName: node.dataRef.dbName,
|
||||
query: ""
|
||||
query: ''
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user