Files
MyGoNavi/frontend/src/components/ConnectionModal.tsx
Syngnat cb18bc3067 feat(driver-proxy): 新增ClickHouse数据源并提供全局代理独立入口
- 新增 ClickHouse 可选驱动实现与 optional-driver-agent provider,补齐驱动注册与清单配置
- 补齐 ClickHouse 连接与 SQL 适配:连接默认端口/用户、LIMIT、标识符引用、只读编辑限制
- 新增全局代理后端能力与前端持久化配置,更新检查和驱动网络请求统一走代理客户端
2026-02-27 16:39:13 +08:00

1819 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from 'react';
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd';
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
import { useStore } from '../store';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
const { Meta } = Card;
const { Text } = Typography;
const MAX_URI_LENGTH = 4096;
const MAX_URI_HOSTS = 32;
const MAX_TIMEOUT_SECONDS = 3600;
const getDefaultPortByType = (type: string) => {
switch (type) {
case 'mysql': return 3306;
case 'diros': return 9030;
case 'sphinx': return 9306;
case 'clickhouse': return 9000;
case 'postgres': return 5432;
case 'redis': return 6379;
case 'tdengine': return 6041;
case 'oracle': return 1521;
case 'dameng': return 5236;
case 'kingbase': return 54321;
case 'sqlserver': return 1433;
case 'mongodb': return 27017;
case 'highgo': return 5866;
case 'mariadb': return 3306;
case 'vastbase': return 5432;
case 'sqlite': return 0;
case 'duckdb': return 0;
default: return 3306;
}
};
const isFileDatabaseType = (type: string) => type === 'sqlite' || type === 'duckdb';
type DriverStatusSnapshot = {
type: string;
name: string;
connectable: boolean;
message?: string;
};
const normalizeDriverType = (value: string): string => {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'postgresql') return 'postgres';
if (normalized === 'doris') return 'diros';
return normalized;
};
const ConnectionModal: React.FC<{
open: boolean;
onClose: () => void;
initialValues?: SavedConnection | null;
onOpenDriverManager?: () => void;
}> = ({ open, onClose, initialValues, onOpenDriverManager }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [useSSH, setUseSSH] = useState(false);
const [useProxy, setUseProxy] = useState(false);
const [dbType, setDbType] = useState('mysql');
const [step, setStep] = useState(1); // 1: Select Type, 2: Configure
const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1
const [testResult, setTestResult] = useState<{ type: 'success' | 'error', message: string } | null>(null);
const [testErrorLogOpen, setTestErrorLogOpen] = useState(false);
const [dbList, setDbList] = useState<string[]>([]);
const [redisDbList, setRedisDbList] = useState<number[]>([]); // Redis databases 0-15
const [mongoMembers, setMongoMembers] = useState<MongoMemberInfo[]>([]);
const [discoveringMembers, setDiscoveringMembers] = useState(false);
const [uriFeedback, setUriFeedback] = useState<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null);
const [typeSelectWarning, setTypeSelectWarning] = useState<{ driverName: string; reason: string } | null>(null);
const [driverStatusMap, setDriverStatusMap] = useState<Record<string, DriverStatusSnapshot>>({});
const [driverStatusLoaded, setDriverStatusLoaded] = useState(false);
const [selectingSSHKey, setSelectingSSHKey] = useState(false);
const testInFlightRef = useRef(false);
const testTimerRef = useRef<number | null>(null);
const addConnection = useStore((state) => state.addConnection);
const updateConnection = useStore((state) => state.updateConnection);
const mysqlTopology = Form.useWatch('mysqlTopology', form) || 'single';
const mongoTopology = Form.useWatch('mongoTopology', form) || 'single';
const mongoSrv = Form.useWatch('mongoSrv', form) || false;
const fetchDriverStatusMap = async (): Promise<Record<string, DriverStatusSnapshot>> => {
const result: Record<string, DriverStatusSnapshot> = {};
const res = await GetDriverStatusList('', '');
if (!res?.success) {
return result;
}
const data = (res?.data || {}) as any;
const drivers = Array.isArray(data.drivers) ? data.drivers : [];
drivers.forEach((item: any) => {
const type = normalizeDriverType(String(item.type || '').trim());
if (!type) return;
result[type] = {
type,
name: String(item.name || item.type || type).trim(),
connectable: !!item.connectable,
message: String(item.message || '').trim() || undefined,
};
});
return result;
};
const refreshDriverStatus = async () => {
try {
const next = await fetchDriverStatusMap();
setDriverStatusMap(next);
} catch {
setDriverStatusMap({});
} finally {
setDriverStatusLoaded(true);
}
};
const resolveDriverUnavailableReason = async (type: string): Promise<string> => {
const normalized = normalizeDriverType(type);
if (!normalized || normalized === 'custom') {
return '';
}
let snapshot = driverStatusMap;
if (!snapshot[normalized]) {
snapshot = await fetchDriverStatusMap();
setDriverStatusMap(snapshot);
}
const status = snapshot[normalized];
if (!status || status.connectable) {
return '';
}
return status.message || `${status.name || normalized} 驱动未安装启用,请先在驱动管理中安装`;
};
const promptInstallDriver = (driverType: string, reason: string) => {
const normalized = normalizeDriverType(driverType);
const snapshot = driverStatusMap[normalized];
const driverName = snapshot?.name || normalized || '当前';
Modal.confirm({
title: `${driverName} 驱动不可用`,
content: reason || `${driverName} 驱动未安装启用,请先在驱动管理中安装`,
okText: '去驱动管理安装',
cancelText: '取消',
onOk: () => {
onOpenDriverManager?.();
},
});
};
const parseHostPort = (raw: string, defaultPort: number): { host: string; port: number } | null => {
const text = String(raw || '').trim();
if (!text) {
return null;
}
if (text.startsWith('[')) {
const closingBracket = text.indexOf(']');
if (closingBracket > 0) {
const host = text.slice(1, closingBracket).trim();
const portText = text.slice(closingBracket + 1).trim().replace(/^:/, '');
const parsedPort = Number(portText);
return {
host: host || 'localhost',
port: Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : defaultPort,
};
}
}
const colonCount = (text.match(/:/g) || []).length;
if (colonCount === 1) {
const splitIndex = text.lastIndexOf(':');
const host = text.slice(0, splitIndex).trim();
const portText = text.slice(splitIndex + 1).trim();
const parsedPort = Number(portText);
return {
host: host || 'localhost',
port: Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : defaultPort,
};
}
return { host: text, port: defaultPort };
};
const toAddress = (host: string, port: number, defaultPort: number) => {
const safeHost = String(host || '').trim() || 'localhost';
const safePort = Number.isFinite(Number(port)) && Number(port) > 0 ? Number(port) : defaultPort;
return `${safeHost}:${safePort}`;
};
const normalizeAddressList = (rawList: unknown, defaultPort: number): string[] => {
const list = Array.isArray(rawList) ? rawList : [];
const seen = new Set<string>();
const result: string[] = [];
list.forEach((entry) => {
const parsed = parseHostPort(String(entry || ''), defaultPort);
if (!parsed) {
return;
}
const normalized = toAddress(parsed.host, parsed.port, defaultPort);
if (seen.has(normalized)) {
return;
}
seen.add(normalized);
result.push(normalized);
});
return result;
};
const isValidUriHostEntry = (entry: string): boolean => {
const text = String(entry || '').trim();
if (!text) return false;
if (text.length > 255) return false;
// 拒绝明显的 DSN 片段或路径/空白,避免把非 URI 主机段误判为合法地址。
if (/[()\\/\s]/.test(text)) return false;
return true;
};
const normalizeMongoSrvHostList = (rawList: unknown, defaultPort: number): string[] => {
const list = Array.isArray(rawList) ? rawList : [];
const seen = new Set<string>();
const result: string[] = [];
list.forEach((entry) => {
const parsed = parseHostPort(String(entry || ''), defaultPort);
if (!parsed?.host) {
return;
}
const host = String(parsed.host).trim();
if (!host || seen.has(host)) {
return;
}
seen.add(host);
result.push(host);
});
return result;
};
const safeDecode = (text: string) => {
try {
return decodeURIComponent(text);
} catch {
return text;
}
};
const normalizeFileDbPath = (rawPath: string): string => {
let pathText = String(rawPath || '').trim();
if (!pathText) {
return '';
}
// 兼容 sqlite:///C:/... 或 sqlite:///C:\... 解析后多出的前导斜杠。
if (/^\/[a-zA-Z]:[\\/]/.test(pathText)) {
pathText = pathText.slice(1);
}
// 兼容历史版本把 Windows 文件路径误拼成 :3306:3306。
const legacyMatch = pathText.match(/^([a-zA-Z]:[\\/].*?)(?::\d+)+$/);
if (legacyMatch?.[1]) {
return legacyMatch[1];
}
return pathText;
};
const parseMultiHostUri = (uriText: string, expectedScheme: string) => {
const prefix = `${expectedScheme}://`;
if (!uriText.toLowerCase().startsWith(prefix)) {
return null;
}
let rest = uriText.slice(prefix.length);
const hashIndex = rest.indexOf('#');
if (hashIndex >= 0) {
rest = rest.slice(0, hashIndex);
}
let queryText = '';
const queryIndex = rest.indexOf('?');
if (queryIndex >= 0) {
queryText = rest.slice(queryIndex + 1);
rest = rest.slice(0, queryIndex);
}
let pathText = '';
const slashIndex = rest.indexOf('/');
if (slashIndex >= 0) {
pathText = rest.slice(slashIndex + 1);
rest = rest.slice(0, slashIndex);
}
let hostText = rest;
let username = '';
let password = '';
const atIndex = rest.lastIndexOf('@');
if (atIndex >= 0) {
const userInfo = rest.slice(0, atIndex);
hostText = rest.slice(atIndex + 1);
const colonIndex = userInfo.indexOf(':');
if (colonIndex >= 0) {
username = safeDecode(userInfo.slice(0, colonIndex));
password = safeDecode(userInfo.slice(colonIndex + 1));
} else {
username = safeDecode(userInfo);
}
}
const hosts = hostText
.split(',')
.map((item) => item.trim())
.filter(Boolean);
return {
username,
password,
hosts,
database: safeDecode(pathText),
params: new URLSearchParams(queryText),
};
};
const parseUriToValues = (uriText: string, type: string): Record<string, any> | null => {
const trimmedUri = String(uriText || '').trim();
if (!trimmedUri) {
return null;
}
if (trimmedUri.length > MAX_URI_LENGTH) {
return null;
}
if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') {
const mysqlDefaultPort = getDefaultPortByType(type);
const parsed = parseMultiHostUri(trimmedUri, 'mysql')
|| parseMultiHostUri(trimmedUri, 'diros')
|| parseMultiHostUri(trimmedUri, 'doris');
if (!parsed) {
return null;
}
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
return null;
}
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const hostList = normalizeAddressList(parsed.hosts, mysqlDefaultPort);
if (!hostList.length) {
return null;
}
const primary = parseHostPort(hostList[0] || `localhost:${mysqlDefaultPort}`, mysqlDefaultPort);
const timeoutValue = Number(parsed.params.get('timeout'));
const topology = String(parsed.params.get('topology') || '').toLowerCase();
return {
host: primary?.host || 'localhost',
port: primary?.port || mysqlDefaultPort,
user: parsed.username,
password: parsed.password,
database: parsed.database || '',
mysqlTopology: hostList.length > 1 || topology === 'replica' ? 'replica' : 'single',
mysqlReplicaHosts: hostList.slice(1),
timeout: Number.isFinite(timeoutValue) && timeoutValue > 0
? Math.min(3600, Math.trunc(timeoutValue))
: undefined,
};
}
if (isFileDatabaseType(type)) {
const rawPath = trimmedUri
.replace(/^sqlite:\/\//i, '')
.replace(/^duckdb:\/\//i, '')
.trim();
if (!rawPath) {
return null;
}
return { host: normalizeFileDbPath(safeDecode(rawPath)) };
}
if (type === 'mongodb') {
const parsed = parseMultiHostUri(trimmedUri, 'mongodb') || parseMultiHostUri(trimmedUri, 'mongodb+srv');
if (!parsed) {
return null;
}
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
return null;
}
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const isSrv = trimmedUri.toLowerCase().startsWith('mongodb+srv://');
const hostList = isSrv
? normalizeMongoSrvHostList(parsed.hosts, 27017)
: normalizeAddressList(parsed.hosts, 27017);
if (!hostList.length) {
return null;
}
const primary = isSrv
? { host: hostList[0] || 'localhost', port: 27017 }
: parseHostPort(hostList[0] || 'localhost:27017', 27017);
const timeoutMs = Number(parsed.params.get('connectTimeoutMS') || parsed.params.get('serverSelectionTimeoutMS'));
return {
host: primary?.host || 'localhost',
port: primary?.port || 27017,
user: parsed.username,
password: parsed.password,
database: parsed.database || '',
mongoTopology: hostList.length > 1 || !!parsed.params.get('replicaSet') ? 'replica' : 'single',
mongoHosts: hostList.slice(1),
mongoSrv: isSrv,
mongoReplicaSet: parsed.params.get('replicaSet') || '',
mongoAuthSource: parsed.params.get('authSource') || '',
mongoReadPreference: parsed.params.get('readPreference') || 'primary',
mongoAuthMechanism: parsed.params.get('authMechanism') || '',
timeout: Number.isFinite(timeoutMs) && timeoutMs > 0
? Math.min(MAX_TIMEOUT_SECONDS, Math.ceil(timeoutMs / 1000))
: undefined,
savePassword: true,
};
}
if (type === 'clickhouse') {
const parsed = parseMultiHostUri(trimmedUri, 'clickhouse');
if (!parsed) {
return null;
}
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
return null;
}
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const hostList = normalizeAddressList(parsed.hosts, 9000);
if (!hostList.length) {
return null;
}
const primary = parseHostPort(hostList[0] || 'localhost:9000', 9000);
return {
host: primary?.host || 'localhost',
port: primary?.port || 9000,
user: parsed.username,
password: parsed.password,
database: parsed.database || '',
};
}
return null;
};
const createUriAwareRequiredRule = (
messageText: string,
validateValue?: (value: unknown) => boolean
) => ({ getFieldValue }: { getFieldValue: (name: string) => unknown }) => ({
validator(_: unknown, value: unknown) {
const uriText = String(getFieldValue('uri') || '').trim();
const type = String(getFieldValue('type') || dbType).trim().toLowerCase();
if (uriText && parseUriToValues(uriText, type)) {
return Promise.resolve();
}
const valid = validateValue
? validateValue(value)
: String(value ?? '').trim() !== '';
return valid ? Promise.resolve() : Promise.reject(new Error(messageText));
}
});
const getUriPlaceholder = () => {
if (dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') {
const defaultPort = getDefaultPortByType(dbType);
const scheme = dbType === 'diros' ? 'diros' : 'mysql';
return `${scheme}://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`;
}
if (isFileDatabaseType(dbType)) {
return dbType === 'duckdb'
? 'duckdb:///Users/name/demo.duckdb'
: 'sqlite:///Users/name/demo.sqlite';
}
if (dbType === 'mongodb') {
return 'mongodb+srv://user:pass@cluster0.example.com/db_name?authSource=admin&authMechanism=SCRAM-SHA-256';
}
if (dbType === 'clickhouse') {
return 'clickhouse://default:pass@127.0.0.1:9000/default';
}
return '例如: postgres://user:pass@127.0.0.1:5432/db_name';
};
const buildUriFromValues = (values: any) => {
const type = String(values.type || '').trim().toLowerCase();
const defaultPort = getDefaultPortByType(type);
const host = String(values.host || 'localhost').trim();
const port = Number(values.port || defaultPort);
const user = String(values.user || '').trim();
const password = String(values.password || '');
const database = String(values.database || '').trim();
const timeout = Number(values.timeout || 30);
const encodedAuth = user
? `${encodeURIComponent(user)}${password ? `:${encodeURIComponent(password)}` : ''}@`
: '';
if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') {
const primary = toAddress(host, port, defaultPort);
const replicas = values.mysqlTopology === 'replica'
? normalizeAddressList(values.mysqlReplicaHosts, defaultPort)
: [];
const hosts = normalizeAddressList([primary, ...replicas], defaultPort);
const params = new URLSearchParams();
if (hosts.length > 1 || values.mysqlTopology === 'replica') {
params.set('topology', 'replica');
}
if (Number.isFinite(timeout) && timeout > 0) {
params.set('timeout', String(timeout));
}
const dbPath = database ? `/${encodeURIComponent(database)}` : '/';
const query = params.toString();
const scheme = type === 'diros' ? 'diros' : 'mysql';
return `${scheme}://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`;
}
if (isFileDatabaseType(type)) {
const pathText = normalizeFileDbPath(String(values.host || '').trim());
if (!pathText) {
return `${type}://`;
}
return `${type}://${encodeURI(pathText)}`;
}
if (type === 'mongodb') {
const useSrv = !!values.mongoSrv;
const primaryAddress = useSrv
? (parseHostPort(host, 27017)?.host || host || 'localhost')
: toAddress(host, port, 27017);
const extraNodes = values.mongoTopology === 'replica'
? (useSrv ? normalizeMongoSrvHostList(values.mongoHosts, 27017) : normalizeAddressList(values.mongoHosts, 27017))
: [];
const hosts = useSrv
? normalizeMongoSrvHostList([primaryAddress, ...extraNodes], 27017)
: normalizeAddressList([primaryAddress, ...extraNodes], 27017);
const scheme = useSrv ? 'mongodb+srv' : 'mongodb';
const params = new URLSearchParams();
const authSource = String(values.mongoAuthSource || database || 'admin').trim();
if (authSource) {
params.set('authSource', authSource);
}
const replicaSet = String(values.mongoReplicaSet || '').trim();
if (replicaSet) {
params.set('replicaSet', replicaSet);
}
const readPreference = String(values.mongoReadPreference || '').trim();
if (readPreference) {
params.set('readPreference', readPreference);
}
const authMechanism = String(values.mongoAuthMechanism || '').trim();
if (authMechanism) {
params.set('authMechanism', authMechanism);
}
if (Number.isFinite(timeout) && timeout > 0) {
params.set('connectTimeoutMS', String(timeout * 1000));
params.set('serverSelectionTimeoutMS', String(timeout * 1000));
}
const dbPath = database ? `/${encodeURIComponent(database)}` : '/';
const query = params.toString();
return `${scheme}://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`;
}
const scheme = type === 'postgres' ? 'postgresql' : type;
const dbPath = database ? `/${encodeURIComponent(database)}` : '';
return `${scheme}://${encodedAuth}${toAddress(host, port, defaultPort)}${dbPath}`;
};
const handleGenerateURI = () => {
try {
const values = form.getFieldsValue(true);
const uri = buildUriFromValues(values);
form.setFieldValue('uri', uri);
setUriFeedback({ type: 'success', message: 'URI 已生成' });
} catch {
setUriFeedback({ type: 'error', message: '生成 URI 失败' });
}
};
const handleParseURI = () => {
try {
const uriText = String(form.getFieldValue('uri') || '').trim();
const type = String(form.getFieldValue('type') || dbType).trim().toLowerCase();
if (!uriText) {
setUriFeedback({ type: 'warning', message: '请先输入 URI' });
return;
}
const parsedValues = parseUriToValues(uriText, type);
if (!parsedValues) {
setUriFeedback({ type: 'error', message: '当前 URI 与数据源类型不匹配,或 URI 格式不支持' });
return;
}
form.setFieldsValue({ ...parsedValues, uri: uriText });
if (testResult) {
setTestResult(null);
}
setUriFeedback({ type: 'success', message: '已根据 URI 回填连接参数' });
} catch {
setUriFeedback({ type: 'error', message: 'URI 解析失败,请检查格式后重试' });
}
};
const handleCopyURI = async () => {
let uriText = String(form.getFieldValue('uri') || '').trim();
if (!uriText) {
const values = form.getFieldsValue(true);
uriText = buildUriFromValues(values);
form.setFieldValue('uri', uriText);
}
if (!uriText) {
setUriFeedback({ type: 'warning', message: '没有可复制的 URI' });
return;
}
try {
await navigator.clipboard.writeText(uriText);
setUriFeedback({ type: 'success', message: 'URI 已复制' });
} catch {
setUriFeedback({ type: 'error', message: '复制失败' });
}
};
const handleSelectSSHKeyFile = async () => {
if (selectingSSHKey) {
return;
}
try {
setSelectingSSHKey(true);
const currentPath = String(form.getFieldValue('sshKeyPath') || '').trim();
const res = await SelectSSHKeyFile(currentPath);
if (res?.success) {
const data = res.data || {};
const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim();
if (selectedPath) {
form.setFieldValue('sshKeyPath', selectedPath);
}
} else if (res?.message !== 'Cancelled') {
message.error(`选择私钥文件失败: ${res?.message || '未知错误'}`);
}
} catch (e: any) {
message.error(`选择私钥文件失败: ${e?.message || String(e)}`);
} finally {
setSelectingSSHKey(false);
}
};
useEffect(() => {
if (open) {
setTestResult(null); // Reset test result
setTestErrorLogOpen(false);
setDbList([]);
setRedisDbList([]);
setMongoMembers([]);
setUriFeedback(null);
setTypeSelectWarning(null);
setDriverStatusLoaded(false);
void refreshDriverStatus();
if (initialValues) {
// Edit mode: Go directly to step 2
setStep(2);
const config: any = initialValues.config || {};
const configType = String(config.type || 'mysql');
const defaultPort = getDefaultPortByType(configType);
const isFileDbConfigType = isFileDatabaseType(configType);
const normalizedHosts = isFileDbConfigType ? [] : normalizeAddressList(config.hosts, defaultPort);
const primaryAddress = isFileDbConfigType
? null
: parseHostPort(
normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort),
defaultPort
);
const primaryHost = isFileDbConfigType
? normalizeFileDbPath(String(config.host || ''))
: (primaryAddress?.host || String(config.host || 'localhost'));
const primaryPort = isFileDbConfigType
? 0
: (primaryAddress?.port || Number(config.port || defaultPort));
const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'diros' || configType === 'sphinx') ? normalizedHosts.slice(1) : [];
const mongoHosts = configType === 'mongodb' ? normalizedHosts.slice(1) : [];
const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0;
const mongoIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mongoHosts.length > 0 || !!config.replicaSet;
form.setFieldsValue({
type: configType,
name: initialValues.name,
host: primaryHost,
port: primaryPort,
user: config.user,
password: config.password,
database: config.database,
uri: config.uri || '',
includeDatabases: initialValues.includeDatabases,
includeRedisDatabases: initialValues.includeRedisDatabases,
useSSH: config.useSSH,
sshHost: config.ssh?.host,
sshPort: config.ssh?.port,
sshUser: config.ssh?.user,
sshPassword: config.ssh?.password,
sshKeyPath: config.ssh?.keyPath,
useProxy: config.useProxy,
proxyType: config.proxy?.type || 'socks5',
proxyHost: config.proxy?.host,
proxyPort: config.proxy?.port,
proxyUser: config.proxy?.user,
proxyPassword: config.proxy?.password,
driver: config.driver,
dsn: config.dsn,
timeout: config.timeout || 30,
mysqlTopology: mysqlIsReplica ? 'replica' : 'single',
mysqlReplicaHosts: mysqlReplicaHosts,
mysqlReplicaUser: config.mysqlReplicaUser || '',
mysqlReplicaPassword: config.mysqlReplicaPassword || '',
mongoTopology: mongoIsReplica ? 'replica' : 'single',
mongoHosts: mongoHosts,
mongoSrv: !!config.mongoSrv,
mongoReplicaSet: config.replicaSet || '',
mongoAuthSource: config.authSource || '',
mongoReadPreference: config.readPreference || 'primary',
mongoAuthMechanism: config.mongoAuthMechanism || '',
savePassword: config.savePassword !== false,
mongoReplicaUser: config.mongoReplicaUser || '',
mongoReplicaPassword: config.mongoReplicaPassword || ''
});
setUseSSH(config.useSSH || false);
setUseProxy(config.useProxy || false);
setDbType(configType);
// 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表
if (configType === 'redis') {
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
}
} else {
// Create mode: Start at step 1
setStep(1);
form.resetFields();
setUseSSH(false);
setUseProxy(false);
setDbType('mysql');
setActiveGroup(0);
}
}
}, [open, initialValues]);
useEffect(() => {
return () => {
if (testTimerRef.current !== null) {
window.clearTimeout(testTimerRef.current);
testTimerRef.current = null;
}
};
}, []);
const handleOk = async () => {
try {
const values = await form.validateFields();
const unavailableReason = await resolveDriverUnavailableReason(values.type);
if (unavailableReason) {
message.warning(unavailableReason);
promptInstallDriver(values.type, unavailableReason);
return;
}
setLoading(true);
const config = await buildConfig(values, true);
const displayHost = String((config as any).host || values.host || '').trim();
const isRedisType = values.type === 'redis';
const newConn = {
id: initialValues ? initialValues.id : Date.now().toString(),
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
};
if (initialValues) {
updateConnection(newConn);
message.success('配置已更新(未连接)');
} else {
addConnection(newConn);
message.success('配置已保存(未连接)');
}
setLoading(false);
form.resetFields();
setUseSSH(false);
setUseProxy(false);
setDbType('mysql');
setStep(1);
onClose();
} catch (e) {
setLoading(false);
}
};
const requestTest = () => {
if (loading) return;
if (testTimerRef.current !== null) return;
testTimerRef.current = window.setTimeout(() => {
testTimerRef.current = null;
handleTest();
}, 0);
};
const buildTestFailureMessage = (reason: unknown, fallback: string) => {
const text = String(reason ?? '').trim();
const normalized = text && text !== 'undefined' && text !== 'null' ? text : fallback;
return `测试失败: ${normalized}`;
};
const handleTest = async () => {
if (testInFlightRef.current) return;
testInFlightRef.current = true;
try {
const values = await form.validateFields();
const unavailableReason = await resolveDriverUnavailableReason(values.type);
if (unavailableReason) {
const failMessage = buildTestFailureMessage(unavailableReason, '驱动未安装启用');
setTestResult({ type: 'error', message: failMessage });
promptInstallDriver(values.type, unavailableReason);
return;
}
setLoading(true);
setTestResult(null);
const config = await buildConfig(values, false);
// Use different API for Redis
const isRedisType = values.type === 'redis';
const res = isRedisType
? await RedisConnect(config as any)
: await TestConnection(config as any);
if (res.success) {
setTestResult({ type: 'success', message: res.message });
if (isRedisType) {
// Redis: generate database list 0-15
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
} else {
// Other databases: fetch database list
const dbRes = await DBGetDatabases(config as any);
if (dbRes.success) {
const dbs = (dbRes.data as any[]).map((row: any) => row.Database || row.database);
setDbList(dbs);
}
}
} else {
const failMessage = buildTestFailureMessage(
res?.message,
'连接被拒绝或参数无效,请检查后重试'
);
setTestResult({ type: 'error', message: failMessage });
}
} catch (e: unknown) {
if (e && typeof e === 'object' && 'errorFields' in e) {
const failMessage = '测试失败: 请先完善必填项后再测试连接';
setTestResult({ type: 'error', message: failMessage });
return;
}
const reason = e instanceof Error
? e.message
: (typeof e === 'string' ? e : '未知异常');
const failMessage = buildTestFailureMessage(reason, '未知异常');
setTestResult({ type: 'error', message: failMessage });
} finally {
testInFlightRef.current = false;
setLoading(false);
}
};
const handleDiscoverMongoMembers = async () => {
if (discoveringMembers || dbType !== 'mongodb') {
return;
}
try {
const values = await form.validateFields();
setDiscoveringMembers(true);
const config = await buildConfig(values, false);
const result = await MongoDiscoverMembers(config as any);
if (!result.success) {
message.error(result.message || '成员发现失败');
return;
}
const data = (result.data as Record<string, any>) || {};
const membersRaw = Array.isArray(data.members) ? data.members : [];
const members: MongoMemberInfo[] = membersRaw
.map((item: any) => ({
host: String(item.host || '').trim(),
role: String(item.role || item.state || 'UNKNOWN').trim(),
state: String(item.state || item.role || 'UNKNOWN').trim(),
stateCode: Number(item.stateCode || 0),
healthy: !!item.healthy,
isSelf: !!item.isSelf,
}))
.filter((item: MongoMemberInfo) => !!item.host);
setMongoMembers(members);
if (!form.getFieldValue('mongoReplicaSet') && data.replicaSet) {
form.setFieldValue('mongoReplicaSet', String(data.replicaSet));
}
message.success(result.message || `发现 ${members.length} 个成员`);
} catch (error: any) {
message.error(error?.message || '成员发现失败');
} finally {
setDiscoveringMembers(false);
}
};
const buildConfig = async (values: any, forPersist: boolean): Promise<ConnectionConfig> => {
const mergedValues = { ...values };
const parsedUriValues = parseUriToValues(mergedValues.uri, mergedValues.type);
const isEmptyField = (value: unknown) => (
value === undefined
|| value === null
|| value === ''
|| value === 0
|| (Array.isArray(value) && value.length === 0)
);
if (parsedUriValues) {
Object.entries(parsedUriValues).forEach(([key, value]) => {
if (isEmptyField((mergedValues as any)[key])) {
(mergedValues as any)[key] = value;
}
});
}
const type = String(mergedValues.type || '').toLowerCase();
const defaultPort = getDefaultPortByType(type);
const isFileDbType = isFileDatabaseType(type);
let primaryHost = 'localhost';
let primaryPort = defaultPort;
if (isFileDbType) {
// 文件型数据库sqlite/duckdb这里的 host 即数据库文件路径,不应参与 host:port 拼接与解析。
primaryHost = normalizeFileDbPath(String(mergedValues.host || '').trim());
primaryPort = 0;
} else {
const parsedPrimary = parseHostPort(
toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort),
defaultPort
);
primaryHost = parsedPrimary?.host || 'localhost';
primaryPort = parsedPrimary?.port || defaultPort;
}
let hosts: string[] = [];
let topology: 'single' | 'replica' | undefined;
let replicaSet = '';
let authSource = '';
let readPreference = '';
let mysqlReplicaUser = '';
let mysqlReplicaPassword = '';
let mongoSrvEnabled = false;
let mongoAuthMechanism = '';
let mongoReplicaUser = '';
let mongoReplicaPassword = '';
const savePassword = type === 'mongodb'
? mergedValues.savePassword !== false
: true;
if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') {
const replicas = mergedValues.mysqlTopology === 'replica'
? normalizeAddressList(mergedValues.mysqlReplicaHosts, defaultPort)
: [];
const allHosts = normalizeAddressList([`${primaryHost}:${primaryPort}`, ...replicas], defaultPort);
if (mergedValues.mysqlTopology === 'replica' || allHosts.length > 1) {
hosts = allHosts;
topology = 'replica';
mysqlReplicaUser = String(mergedValues.mysqlReplicaUser || '').trim();
mysqlReplicaPassword = String(mergedValues.mysqlReplicaPassword || '');
} else {
topology = 'single';
}
}
if (type === 'mongodb') {
mongoSrvEnabled = !!mergedValues.mongoSrv;
const extraHosts = mergedValues.mongoTopology === 'replica'
? (mongoSrvEnabled
? normalizeMongoSrvHostList(mergedValues.mongoHosts, defaultPort)
: normalizeAddressList(mergedValues.mongoHosts, defaultPort))
: [];
const primarySeed = mongoSrvEnabled ? primaryHost : `${primaryHost}:${primaryPort}`;
const allHosts = mongoSrvEnabled
? normalizeMongoSrvHostList([primarySeed, ...extraHosts], defaultPort)
: normalizeAddressList([primarySeed, ...extraHosts], defaultPort);
if (mergedValues.mongoTopology === 'replica' || allHosts.length > 1 || mergedValues.mongoReplicaSet) {
hosts = allHosts;
topology = 'replica';
mongoReplicaUser = String(mergedValues.mongoReplicaUser || '').trim();
mongoReplicaPassword = String(mergedValues.mongoReplicaPassword || '');
} else {
topology = 'single';
}
replicaSet = String(mergedValues.mongoReplicaSet || '').trim();
authSource = String(mergedValues.mongoAuthSource || mergedValues.database || 'admin').trim();
readPreference = String(mergedValues.mongoReadPreference || 'primary').trim();
mongoAuthMechanism = String(mergedValues.mongoAuthMechanism || '').trim().toUpperCase();
}
const sshConfig = mergedValues.useSSH ? {
host: mergedValues.sshHost,
port: Number(mergedValues.sshPort),
user: mergedValues.sshUser,
password: mergedValues.sshPassword || "",
keyPath: mergedValues.sshKeyPath || ""
} : { host: "", port: 22, user: "", password: "", keyPath: "" };
const effectiveUseProxy = !isFileDbType && !!mergedValues.useProxy;
const proxyTypeRaw = String(mergedValues.proxyType || 'socks5').toLowerCase();
const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5';
const proxyConfig: NonNullable<ConnectionConfig['proxy']> = effectiveUseProxy ? {
type: proxyType,
host: String(mergedValues.proxyHost || '').trim(),
port: Number(mergedValues.proxyPort || (proxyTypeRaw === 'http' ? 8080 : 1080)),
user: String(mergedValues.proxyUser || '').trim(),
password: mergedValues.proxyPassword || "",
} : {
type: 'socks5',
host: '',
port: 1080,
user: '',
password: '',
};
const keepPassword = !forPersist || savePassword;
return {
type: mergedValues.type,
host: primaryHost,
port: Number(primaryPort || 0),
user: mergedValues.user || "",
password: keepPassword ? (mergedValues.password || "") : "",
savePassword: savePassword,
database: mergedValues.database || "",
useSSH: !!mergedValues.useSSH,
ssh: sshConfig,
useProxy: effectiveUseProxy,
proxy: proxyConfig,
driver: mergedValues.driver,
dsn: mergedValues.dsn,
timeout: Number(mergedValues.timeout || 30),
uri: String(mergedValues.uri || '').trim(),
hosts: hosts,
topology: topology,
mysqlReplicaUser: mysqlReplicaUser,
mysqlReplicaPassword: keepPassword ? mysqlReplicaPassword : "",
replicaSet: replicaSet,
authSource: authSource,
readPreference: readPreference,
mongoSrv: mongoSrvEnabled,
mongoAuthMechanism: mongoAuthMechanism,
mongoReplicaUser: mongoReplicaUser,
mongoReplicaPassword: keepPassword ? mongoReplicaPassword : "",
};
};
const handleTypeSelect = async (type: string) => {
const unavailableReason = await resolveDriverUnavailableReason(type);
if (unavailableReason) {
const normalized = normalizeDriverType(type);
const driverName = driverStatusMap[normalized]?.name || type;
setTypeSelectWarning({ driverName, reason: unavailableReason });
return;
}
setTypeSelectWarning(null);
setDbType(type);
form.setFieldsValue({ type: type });
const defaultPort = getDefaultPortByType(type);
if (isFileDatabaseType(type)) {
setUseSSH(false);
setUseProxy(false);
form.setFieldsValue({
host: '',
port: 0,
user: '',
password: '',
database: '',
useSSH: false,
sshHost: '',
sshPort: 22,
sshUser: '',
sshPassword: '',
sshKeyPath: '',
useProxy: false,
proxyType: 'socks5',
proxyHost: '',
proxyPort: 1080,
proxyUser: '',
proxyPassword: '',
mysqlTopology: 'single',
mongoTopology: 'single',
mongoSrv: false,
mongoReadPreference: 'primary',
mongoReplicaSet: '',
mongoAuthSource: '',
mongoAuthMechanism: '',
savePassword: true,
mysqlReplicaHosts: [],
mongoHosts: [],
mysqlReplicaUser: '',
mysqlReplicaPassword: '',
mongoReplicaUser: '',
mongoReplicaPassword: '',
});
} else if (type !== 'custom') {
const defaultUser = type === 'clickhouse' ? 'default' : 'root';
form.setFieldsValue({
user: defaultUser,
database: '',
port: defaultPort,
mysqlTopology: 'single',
mongoTopology: 'single',
mongoSrv: false,
mongoReadPreference: 'primary',
mongoReplicaSet: '',
mongoAuthSource: '',
mongoAuthMechanism: '',
savePassword: true,
mysqlReplicaHosts: [],
mongoHosts: [],
mysqlReplicaUser: '',
mysqlReplicaPassword: '',
mongoReplicaUser: '',
mongoReplicaPassword: '',
});
}
setMongoMembers([]);
setStep(2);
};
const isFileDb = isFileDatabaseType(dbType);
const isCustom = dbType === 'custom';
const isRedis = dbType === 'redis';
const currentDriverType = normalizeDriverType(dbType);
const currentDriverSnapshot = driverStatusMap[currentDriverType];
const currentDriverUnavailableReason = currentDriverType !== 'custom'
&& currentDriverSnapshot
&& !currentDriverSnapshot.connectable
? (currentDriverSnapshot.message || `${currentDriverSnapshot.name || dbType} 驱动未安装启用`)
: '';
const driverStatusChecking = currentDriverType !== 'custom' && !driverStatusLoaded && step === 2;
const dbTypeGroups = [
{ label: '关系型数据库', items: [
{ key: 'mysql', name: 'MySQL', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#00758F' }} /> },
{ key: 'mariadb', name: 'MariaDB', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#003545' }} /> },
{ key: 'diros', name: 'Diros', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#0050b3' }} /> },
{ key: 'sphinx', name: 'Sphinx', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#2F5D62' }} /> },
{ key: 'clickhouse', name: 'ClickHouse', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#FFCC01' }} /> },
{ key: 'postgres', name: 'PostgreSQL', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#336791' }} /> },
{ key: 'sqlserver', name: 'SQL Server', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#CC2927' }} /> },
{ key: 'sqlite', name: 'SQLite', icon: <FileTextOutlined style={{ fontSize: 24, color: '#003B57' }} /> },
{ key: 'duckdb', name: 'DuckDB', icon: <FileTextOutlined style={{ fontSize: 24, color: '#f59e0b' }} /> },
{ key: 'oracle', name: 'Oracle', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#F80000' }} /> },
]},
{ label: '国产数据库', items: [
{ key: 'dameng', name: 'Dameng (达梦)', icon: <CloudServerOutlined style={{ fontSize: 24, color: '#1890ff' }} /> },
{ key: 'kingbase', name: 'Kingbase (人大金仓)', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#faad14' }} /> },
{ key: 'highgo', name: 'HighGo (瀚高)', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#00a854' }} /> },
{ key: 'vastbase', name: 'Vastbase (海量)', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#1a6dff' }} /> },
]},
{ label: 'NoSQL', items: [
{ key: 'mongodb', name: 'MongoDB', icon: <CloudServerOutlined style={{ fontSize: 24, color: '#47A248' }} /> },
{ key: 'redis', name: 'Redis', icon: <CloudOutlined style={{ fontSize: 24, color: '#DC382D' }} /> },
]},
{ label: '时序数据库', items: [
{ key: 'tdengine', name: 'TDengine', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#2F54EB' }} /> },
]},
{ label: '其他', items: [
{ key: 'custom', name: 'Custom (自定义)', icon: <AppstoreAddOutlined style={{ fontSize: 24, color: '#595959' }} /> },
]},
];
const dbTypes = dbTypeGroups.flatMap(g => g.items);
const renderStep1 = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{typeSelectWarning && (
<Alert
type="warning"
showIcon
closable
message={`${typeSelectWarning.driverName} 驱动未启用`}
description={(
<Space size={8}>
<span>{typeSelectWarning.reason}</span>
<Button type="link" size="small" onClick={() => onOpenDriverManager?.()}>
</Button>
</Space>
)}
onClose={() => setTypeSelectWarning(null)}
/>
)}
<div style={{ display: 'flex', height: 360 }}>
{/* 左侧分类导航 */}
<div style={{ width: 120, borderRight: '1px solid #f0f0f0', paddingRight: 8, flexShrink: 0 }}>
{dbTypeGroups.map((group, idx) => (
<div
key={group.label}
onClick={() => setActiveGroup(idx)}
style={{
padding: '10px 12px',
cursor: 'pointer',
borderRadius: 6,
marginBottom: 4,
background: activeGroup === idx ? '#e6f4ff' : 'transparent',
color: activeGroup === idx ? '#1677ff' : undefined,
fontWeight: activeGroup === idx ? 500 : 400,
transition: 'all 0.2s',
fontSize: 13,
}}
>
{group.label}
</div>
))}
</div>
{/* 右侧数据源卡片 */}
<div style={{ flex: 1, paddingLeft: 16, overflowY: 'auto', overflowX: 'hidden' }}>
<Row gutter={[12, 12]}>
{dbTypeGroups[activeGroup]?.items.map(item => (
<Col span={8} key={item.key}>
<Card
hoverable
onClick={() => { void handleTypeSelect(item.key); }}
style={{ textAlign: 'center', cursor: 'pointer', height: 100 }}
styles={{ body: { padding: '16px 8px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%' } }}
>
<div style={{ marginBottom: 8 }}>{item.icon}</div>
<Text strong style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '100%' }}>{item.name}</Text>
</Card>
</Col>
))}
</Row>
</div>
</div>
</div>
);
const renderStep2 = () => (
<Form
form={form}
layout="vertical"
initialValues={{
type: 'mysql',
host: 'localhost',
port: 3306,
database: '',
user: 'root',
useSSH: false,
sshPort: 22,
useProxy: false,
proxyType: 'socks5',
proxyPort: 1080,
timeout: 30,
uri: '',
mysqlTopology: 'single',
mongoTopology: 'single',
mongoSrv: false,
mongoReadPreference: 'primary',
mongoAuthMechanism: '',
savePassword: true,
mysqlReplicaHosts: [],
mongoHosts: [],
mysqlReplicaUser: '',
mysqlReplicaPassword: '',
mongoReplicaUser: '',
mongoReplicaPassword: '',
}}
onValuesChange={(changed) => {
if (testResult) {
setTestResult(null); // Clear result on change
setTestErrorLogOpen(false);
}
if (changed.uri !== undefined || changed.type !== undefined) {
setUriFeedback(null);
}
if (changed.useSSH !== undefined) setUseSSH(changed.useSSH);
if (changed.useProxy !== undefined) setUseProxy(changed.useProxy);
if (changed.proxyType !== undefined) {
const nextType = String(changed.proxyType || 'socks5').toLowerCase();
if (nextType === 'http') {
const currentPort = Number(form.getFieldValue('proxyPort') || 0);
if (!currentPort || currentPort === 1080) {
form.setFieldValue('proxyPort', 8080);
}
} else {
const currentPort = Number(form.getFieldValue('proxyPort') || 0);
if (!currentPort || currentPort === 8080) {
form.setFieldValue('proxyPort', 1080);
}
}
}
// Type change handled by step 1, but keep sync if select changes (hidden now)
if (changed.type !== undefined) setDbType(changed.type);
if (
changed.type !== undefined
|| changed.host !== undefined
|| changed.port !== undefined
|| changed.mongoHosts !== undefined
|| changed.mongoTopology !== undefined
|| changed.mongoSrv !== undefined
) {
setMongoMembers([]);
}
}}
>
{/* Hidden Type Field to keep form value synced */}
<Form.Item name="type" hidden><Input /></Form.Item>
<Form.Item name="name" label="连接名称">
<Input placeholder="例如:本地测试库" />
</Form.Item>
<Form.Item
name="uri"
label="连接 URI可复制粘贴"
help="支持从参数生成、复制到剪贴板,或粘贴后一键解析回填参数"
>
<Input.TextArea rows={2} placeholder={getUriPlaceholder()} />
</Form.Item>
<Space size={8} style={{ marginBottom: 12 }}>
<Button onClick={handleGenerateURI}> URI</Button>
<Button onClick={handleParseURI}> URI </Button>
<Button onClick={handleCopyURI}> URI</Button>
</Space>
{uriFeedback && (
<Alert
showIcon
closable
type={uriFeedback.type}
message={uriFeedback.message}
onClose={() => setUriFeedback(null)}
style={{ marginBottom: 12 }}
/>
)}
{currentDriverUnavailableReason && (
<Alert
showIcon
type="warning"
style={{ marginBottom: 12 }}
message="当前数据源驱动未启用"
description={(
<Space size={8}>
<span>{currentDriverUnavailableReason}</span>
<Button type="link" size="small" onClick={() => onOpenDriverManager?.()}>
</Button>
</Space>
)}
/>
)}
{isCustom ? (
<>
<Form.Item name="driver" label="驱动名称 (Driver Name)" rules={[{ required: true, message: '请输入驱动名称' }]} help="已支持: mysql, postgres, sqlite, oracle, dm, kingbase">
<Input placeholder="例如: mysql, postgres" />
</Form.Item>
<Form.Item name="dsn" label="连接字符串 (DSN)" rules={[{ required: true, message: '请输入连接字符串' }]}>
<Input.TextArea rows={3} placeholder="例如: user:pass@tcp(localhost:3306)/dbname?charset=utf8" />
</Form.Item>
</>
) : (
<>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item
name="host"
label={isFileDb ? "文件路径 (绝对路径)" : "主机地址 (Host)"}
rules={[createUriAwareRequiredRule('请输入地址/路径')]}
style={{ flex: 1 }}
>
<Input
placeholder={isFileDb ? (dbType === 'duckdb' ? "/path/to/db.duckdb" : "/path/to/db.sqlite") : "localhost"}
onDoubleClick={requestTest}
/>
</Form.Item>
{!isFileDb && (
<Form.Item
name="port"
label="端口 (Port)"
rules={[createUriAwareRequiredRule('请输入端口号', (value) => Number(value) > 0)]}
style={{ width: 100 }}
>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
)}
</div>
{(dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase') && (
<Form.Item
name="database"
label="默认连接数据库(可选)"
help="留空会自动尝试 postgres、template1、与当前用户名同名数据库"
>
<Input placeholder="例如appdb" />
</Form.Item>
)}
{(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && (
<>
<Form.Item name="mysqlTopology" label="连接模式">
<Select
options={[
{ value: 'single', label: '单机模式' },
{ value: 'replica', label: '主从模式(优先主库,可切换从库)' },
]}
/>
</Form.Item>
{mysqlTopology === 'replica' && (
<>
<Form.Item
name="mysqlReplicaHosts"
label="从库地址列表"
help="可输入多个从库地址格式host:port回车确认"
>
<Select mode="tags" placeholder="例如10.10.0.12:3306、10.10.0.13:3306" tokenSeparators={[',', ';', ' ']} />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="mysqlReplicaUser" label="从库用户名(可选)" style={{ flex: 1 }}>
<Input placeholder="留空沿用主库用户名" />
</Form.Item>
<Form.Item name="mysqlReplicaPassword" label="从库密码(可选)" style={{ flex: 1 }}>
<Input.Password placeholder="留空沿用主库密码" />
</Form.Item>
</div>
</>
)}
</>
)}
{dbType === 'mongodb' && (
<>
<Form.Item name="mongoSrv" valuePropName="checked" style={{ marginBottom: 12 }}>
<Checkbox>使 SRV mongodb+srv</Checkbox>
</Form.Item>
<Form.Item name="mongoTopology" label="连接模式">
<Select
options={[
{ value: 'single', label: '单机模式' },
{ value: 'replica', label: '主从/副本集模式' },
]}
/>
</Form.Item>
{mongoSrv && useSSH && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
message="SRV 记录模式暂不支持 SSH 隧道,请关闭其中一项后再测试连接"
/>
)}
{mongoTopology === 'replica' && (
<>
{!mongoSrv && (
<Form.Item
name="mongoHosts"
label="附加节点地址"
help="主节点使用上方主机地址这里填写其余节点格式host:port"
>
<Select mode="tags" placeholder="例如10.10.0.22:27017、10.10.0.23:27017" tokenSeparators={[',', ';', ' ']} />
</Form.Item>
)}
{mongoSrv && (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="SRV 模式将通过 DNS 自动发现成员,无需手动填写附加节点地址"
/>
)}
<Form.Item name="mongoReplicaSet" label="Replica Set 名称">
<Input placeholder="例如rs0" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="mongoReplicaUser" label="从库用户名(可选)" style={{ flex: 1 }}>
<Input placeholder="留空沿用主库用户名" />
</Form.Item>
<Form.Item name="mongoReplicaPassword" label="从库密码(可选)" style={{ flex: 1 }}>
<Input.Password placeholder="留空沿用主库密码" />
</Form.Item>
</div>
<Space size={8} style={{ marginBottom: 12 }}>
<Button onClick={handleDiscoverMongoMembers} loading={discoveringMembers}></Button>
<Text type="secondary"></Text>
</Space>
{mongoMembers.length > 0 && (
<Table
size="small"
pagination={false}
rowKey={(record) => `${record.host}-${record.state}`}
dataSource={mongoMembers}
style={{ marginBottom: 12 }}
columns={[
{
title: '成员',
dataIndex: 'host',
width: '48%',
render: (value: string, record: MongoMemberInfo) => (
<span>
{value}
{record.isSelf ? <Tag color="processing" style={{ marginLeft: 8 }}></Tag> : null}
</span>
),
},
{
title: '状态',
dataIndex: 'state',
width: '32%',
render: (value: string) => {
const state = String(value || '').toUpperCase();
let color: string = 'default';
if (state === 'PRIMARY') color = 'success';
else if (state === 'SECONDARY' || state === 'PASSIVE') color = 'blue';
else if (state === 'ARBITER') color = 'purple';
else if (state === 'DOWN' || state === 'REMOVED' || state === 'UNKNOWN') color = 'error';
return <Tag color={color}>{state || 'UNKNOWN'}</Tag>;
},
},
{
title: '健康',
dataIndex: 'healthy',
width: '20%',
render: (value: boolean) => (
<Tag color={value ? 'success' : 'error'}>{value ? '正常' : '异常'}</Tag>
),
},
]}
/>
)}
</>
)}
<Form.Item name="mongoAuthSource" label="认证库 (authSource)">
<Input placeholder="默认使用 database 或 admin" />
</Form.Item>
<Form.Item name="mongoReadPreference" label="读偏好 (readPreference)">
<Select
options={[
{ value: 'primary', label: 'primary' },
{ value: 'primaryPreferred', label: 'primaryPreferred' },
{ value: 'secondary', label: 'secondary' },
{ value: 'secondaryPreferred', label: 'secondaryPreferred' },
{ value: 'nearest', label: 'nearest' },
]}
/>
</Form.Item>
</>
)}
{/* Redis specific: password only, no username */}
{isRedis && (
<>
<Form.Item name="password" label="密码 (可选)">
<Input.Password placeholder="Redis 密码(如果设置了 requirepass" />
</Form.Item>
<Form.Item name="includeRedisDatabases" label="显示数据库 (留空显示全部)" help="连接测试成功后可选择">
<Select mode="multiple" placeholder="选择显示的数据库 (0-15)" allowClear>
{redisDbList.map(db => <Select.Option key={db} value={db}>db{db}</Select.Option>)}
</Select>
</Form.Item>
</>
)}
{/* Non-Redis, non-SQLite: username and password */}
{!isFileDb && !isRedis && (
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item
name="user"
label="用户名"
rules={[createUriAwareRequiredRule('请输入用户名')]}
style={{ flex: 1 }}
>
<Input />
</Form.Item>
<Form.Item name="password" label="密码" style={{ flex: 1 }}>
<Input.Password />
</Form.Item>
{dbType === 'mongodb' && (
<Form.Item name="mongoAuthMechanism" label="验证方式" style={{ width: 160 }}>
<Select
allowClear
placeholder="自动协商"
options={[
{ value: 'SCRAM-SHA-1', label: 'SCRAM-SHA-1' },
{ value: 'SCRAM-SHA-256', label: 'SCRAM-SHA-256' },
{ value: 'MONGODB-AWS', label: 'MONGODB-AWS' },
]}
/>
</Form.Item>
)}
</div>
)}
{dbType === 'mongodb' && (
<Form.Item name="savePassword" valuePropName="checked" style={{ marginTop: -6 }}>
<Checkbox></Checkbox>
</Form.Item>
)}
{!isFileDb && !isRedis && (
<Form.Item name="includeDatabases" label="显示数据库 (留空显示全部)" help="连接测试成功后可选择">
<Select mode="multiple" placeholder="选择显示的数据库" allowClear>
{dbList.map(db => <Select.Option key={db} value={db}>{db}</Select.Option>)}
</Select>
</Form.Item>
)}
{!isFileDb && (
<>
<Divider style={{ margin: '12px 0' }} />
<Form.Item name="useSSH" valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox>使 SSH (SSH Tunnel)</Checkbox>
</Form.Item>
{useSSH && (
<div style={{ padding: '12px', background: '#f5f5f5', borderRadius: 6, marginTop: 12 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="sshHost" label="SSH 主机 (域名或IP)" rules={[{ required: useSSH, message: '请输入SSH主机' }]} style={{ flex: 1 }}>
<Input placeholder="例如: ssh.example.com 或 192.168.1.100" />
</Form.Item>
<Form.Item name="sshPort" label="端口" rules={[{ required: useSSH, message: '请输入SSH端口' }]} style={{ width: 100 }}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="sshUser" label="SSH 用户" rules={[{ required: useSSH, message: '请输入SSH用户' }]} style={{ flex: 1 }}>
<Input placeholder="root" />
</Form.Item>
<Form.Item name="sshPassword" label="SSH 密码" style={{ flex: 1 }}>
<Input.Password placeholder="密码" />
</Form.Item>
</div>
<Form.Item label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
<Space.Compact style={{ width: '100%' }}>
<Form.Item name="sshKeyPath" noStyle>
<Input placeholder="绝对路径" />
</Form.Item>
<Button onClick={handleSelectSSHKeyFile} loading={selectingSSHKey}>
...
</Button>
</Space.Compact>
</Form.Item>
</div>
)}
<Divider style={{ margin: '12px 0' }} />
<Form.Item name="useProxy" valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox>使 (SOCKS5 / HTTP CONNECT)</Checkbox>
</Form.Item>
{useProxy && (
<div style={{ padding: '12px', background: '#f5f5f5', borderRadius: 6, marginTop: 12 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="proxyType" label="代理类型" rules={[{ required: useProxy, message: '请选择代理类型' }]} style={{ width: 180 }}>
<Select options={[
{ value: 'socks5', label: 'SOCKS5' },
{ value: 'http', label: 'HTTP CONNECT' },
]} />
</Form.Item>
<Form.Item name="proxyHost" label="代理主机" rules={[{ required: useProxy, message: '请输入代理主机' }]} style={{ flex: 1 }}>
<Input placeholder="例如: 127.0.0.1 或 proxy.company.com" />
</Form.Item>
<Form.Item name="proxyPort" label="端口" rules={[{ required: useProxy, message: '请输入代理端口' }]} style={{ width: 120 }}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="proxyUser" label="代理用户名(可选)" style={{ flex: 1 }}>
<Input placeholder="留空表示无认证" />
</Form.Item>
<Form.Item name="proxyPassword" label="代理密码(可选)" style={{ flex: 1 }}>
<Input.Password placeholder="留空表示无认证" />
</Form.Item>
</div>
</div>
)}
<Divider style={{ margin: '12px 0' }} />
<Collapse
ghost
items={[{
key: 'advanced',
label: '高级连接',
children: (
<Form.Item
name="timeout"
label="连接超时 (秒)"
help="数据库连接超时时间,默认 30 秒"
rules={[{ type: 'number', min: 1, max: 300, message: '超时时间范围: 1-300 秒' }]}
>
<InputNumber style={{ width: '100%' }} min={1} max={300} placeholder="30" />
</Form.Item>
)
}]}
/>
</>
)}
</>
)}
</Form>
);
const getFooter = () => {
if (step === 1) {
return [
<Button key="cancel" onClick={onClose}></Button>
];
}
const isTestSuccess = testResult?.type === 'success';
const hasTestError = !!testResult && !isTestSuccess;
const operationBlocked = !!currentDriverUnavailableReason || driverStatusChecking;
return (
<div style={{ display: 'flex', width: '100%', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
{!initialValues && <Button key="back" onClick={() => setStep(1)}></Button>}
{testResult ? (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 24,
padding: '0 10px',
borderRadius: 999,
border: isTestSuccess ? '1px solid rgba(82, 196, 26, 0.35)' : '1px solid rgba(255, 77, 79, 0.35)',
background: isTestSuccess ? 'rgba(82, 196, 26, 0.10)' : 'rgba(255, 77, 79, 0.10)',
color: isTestSuccess ? '#389e0d' : '#cf1322',
fontSize: 12,
lineHeight: '22px',
whiteSpace: 'nowrap',
boxSizing: 'border-box',
}}
>
{isTestSuccess ? <CheckCircleFilled /> : <CloseCircleFilled />}
<span>{isTestSuccess ? '连接成功' : '连接失败'}</span>
</span>
) : null}
{hasTestError && (
<Button
size="small"
icon={<FileTextOutlined />}
style={{
height: 24,
borderRadius: 999,
padding: '0 10px',
borderColor: '#ffccc7',
background: '#fff2f0',
color: '#cf1322',
}}
onClick={() => setTestErrorLogOpen(true)}
>
</Button>
)}
</div>
<Space size={8} style={{ flexShrink: 0 }}>
<Button key="test" loading={loading} disabled={operationBlocked} onClick={requestTest}></Button>
<Button key="cancel" onClick={onClose}></Button>
<Button key="submit" type="primary" loading={loading} disabled={operationBlocked} onClick={handleOk}></Button>
</Space>
</div>
);
};
const getTitle = () => {
if (step === 1) return "选择数据源类型";
const typeName = dbTypes.find(t => t.key === dbType)?.name || dbType;
return initialValues ? "编辑连接" : `新建 ${typeName} 连接`;
};
const modalBodyStyle = step === 1
? { padding: '16px 24px', overflow: 'hidden' as const }
: {
padding: '16px 24px',
overflowY: 'auto' as const,
overflowX: 'hidden' as const,
};
return (
<>
<Modal
title={getTitle()}
open={open}
onCancel={onClose}
footer={getFooter()}
centered
wrapClassName="connection-modal-wrap"
width={step === 1 ? 650 : 600}
zIndex={10001}
destroyOnHidden
maskClosable={false}
styles={{ body: modalBodyStyle }}
>
{step === 1 ? renderStep1() : renderStep2()}
</Modal>
<Modal
title="测试连接失败原因"
open={testErrorLogOpen}
onCancel={() => setTestErrorLogOpen(false)}
centered
width={760}
zIndex={10002}
destroyOnHidden
footer={[
<Button key="close" onClick={() => setTestErrorLogOpen(false)}></Button>,
]}
>
<pre
style={{
margin: 0,
maxHeight: '50vh',
overflowY: 'auto',
padding: 12,
borderRadius: 6,
background: '#fff2f0',
border: '1px solid #ffccc7',
color: '#a8071a',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
lineHeight: '20px',
fontSize: 13,
}}
>
{String(testResult?.message || '暂无失败日志')}
</pre>
</Modal>
</>
);
};
export default ConnectionModal;