feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿
- refs #166
This commit is contained in:
杨国锋
2026-03-08 18:42:27 +08:00
parent e521d2125f
commit b85c7529ec
14 changed files with 35 additions and 342 deletions

View File

@@ -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) {

View File

@@ -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: ''
});
}
},