Files
MyGoNavi/frontend/src/components/ConnectionModal.tsx
Syngnat f992ad72e6 feat(mongodb): 支持无认证模式连接低版本 MongoDB 实例
- 连接表单:验证方式新增"无认证 (None)"选项,MongoDB 用户名改为非必填
- URI 构建:当 MongoAuthMechanism 为 NONE 时跳过 user/password/authSource/authMechanism
- 兼容优化:无用户名时不再默认设置 authSource=admin,避免驱动对无密码实例发起认证
- 双版本同步:mongodb_impl.go 与 mongodb_impl_v1.go 同步修改
- refs #303
2026-04-01 16:46:27 +08:00

2835 lines
141 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, useMemo } 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, LinkOutlined, EditOutlined, AppstoreOutlined, BgColorsOutlined } from '@ant-design/icons';
import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICON_COLORS } from './DatabaseIcons';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, 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 CONNECTION_MODAL_WIDTH = 960;
const CONNECTION_MODAL_BODY_HEIGHT = 620;
const STEP1_SIDEBAR_DIVIDER_DARK = 'rgba(255, 255, 255, 0.16)';
const STEP1_SIDEBAR_DIVIDER_LIGHT = 'rgba(0, 0, 0, 0.08)';
const getDefaultPortByType = (type: string) => {
switch (type) {
case 'mysql': return 3306;
case 'doris':
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 singleHostUriSchemesByType: Record<string, string[]> = {
postgres: ['postgresql', 'postgres'],
clickhouse: ['clickhouse'],
oracle: ['oracle'],
sqlserver: ['sqlserver'],
redis: ['redis'],
tdengine: ['tdengine'],
dameng: ['dameng', 'dm'],
kingbase: ['kingbase'],
highgo: ['highgo'],
vastbase: ['vastbase'],
};
const sslSupportedTypes = new Set([
'mysql',
'mariadb',
'diros',
'sphinx',
'dameng',
'clickhouse',
'postgres',
'sqlserver',
'oracle',
'kingbase',
'highgo',
'vastbase',
'mongodb',
'redis',
'tdengine',
]);
const supportsSSLForType = (type: string) => sslSupportedTypes.has(String(type || '').trim().toLowerCase());
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 [useSSL, setUseSSL] = useState(false);
const [useSSH, setUseSSH] = useState(false);
const [useProxy, setUseProxy] = useState(false);
const [useHttpTunnel, setUseHttpTunnel] = 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 [activeConfigSection, setActiveConfigSection] = useState<'basic' | 'network' | 'appearance'>('basic');
const [customIconType, setCustomIconType] = useState<string | undefined>(undefined);
const [customIconColor, setCustomIconColor] = useState<string | undefined>(undefined);
const [activeNetworkConfig, setActiveNetworkConfig] = useState<'ssl' | 'ssh' | 'proxy' | 'httpTunnel'>('ssl');
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 [selectingDbFile, setSelectingDbFile] = 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 theme = useStore((state) => state.theme);
const appearance = useStore((state) => state.appearance);
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const mysqlTopology = Form.useWatch('mysqlTopology', form) || 'single';
const mongoTopology = Form.useWatch('mongoTopology', form) || 'single';
const mongoSrv = Form.useWatch('mongoSrv', form) || false;
const redisTopology = Form.useWatch('redisTopology', form) || 'single';
const isMySQLLike = dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx';
const isSSLType = supportsSSLForType(dbType);
const sslHintText = isMySQLLike
? '当 MySQL/MariaDB/Doris/Sphinx 开启安全传输策略时,请启用 SSL本地自签证书场景可先用 Preferred 或 Skip Verify。'
: dbType === 'dameng'
? '达梦驱动启用 SSL 需要客户端证书与私钥路径sslCertPath / sslKeyPath。'
: dbType === 'sqlserver'
? 'SQL Server 推荐在生产环境使用 Required并关闭 TrustServerCertificate。'
: dbType === 'mongodb'
? 'MongoDB 可通过 TLS 保护连接,证书校验异常时可先用 Skip Verify 验证连通性。'
: '建议优先使用 Required仅在测试环境或自签证书场景使用 Skip Verify。';
const getSectionBg = (darkHex: string) => {
if (!darkMode) {
return `rgba(245, 245, 245, ${Math.max(effectiveOpacity, 0.92)})`;
}
const hex = darkHex.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${Math.max(effectiveOpacity, 0.82)})`;
};
const step1SidebarDividerColor = darkMode ? STEP1_SIDEBAR_DIVIDER_DARK : STEP1_SIDEBAR_DIVIDER_LIGHT;
const step1SidebarActiveBg = darkMode ? 'rgba(246, 196, 83, 0.20)' : '#e6f4ff';
const step1SidebarActiveColor = darkMode ? '#ffd666' : '#1677ff';
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
const tunnelSectionStyle: React.CSSProperties = {
padding: '12px',
background: getSectionBg('#2a2a2a'),
borderRadius: 6,
marginTop: 12,
border: darkMode ? '1px solid rgba(255, 255, 255, 0.16)' : '1px solid rgba(0, 0, 0, 0.06)',
};
const modalShellStyle = useMemo(() => ({
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
}), [overlayTheme]);
const modalInnerSectionStyle = useMemo(() => ({
padding: 14,
borderRadius: 14,
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
}), [overlayTheme]);
const modalMutedTextStyle = useMemo(() => ({
color: overlayTheme.mutedText,
fontSize: 12,
lineHeight: 1.6,
}), [overlayTheme]);
const renderConnectionModalTitle = (icon: React.ReactNode, title: string, description: string) => (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
{icon}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{title}</div>
<div style={{ marginTop: 4, color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6 }}>{description}</div>
</div>
</div>
);
const getConnectionOptionCardStyle = (_enabled: boolean): React.CSSProperties => ({
padding: '12px 14px',
borderRadius: 14,
border: '1px solid transparent',
background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)',
boxShadow: darkMode
? 'inset 0 0 0 1px rgba(255,255,255,0.028)'
: 'inset 0 0 0 1px rgba(16,24,40,0.03)',
transition: 'all 120ms ease',
});
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 parseSingleHostUri = (
uriText: string,
expectedSchemes: string[],
defaultPort: number,
): { host: string; port: number; username: string; password: string; database: string; params: URLSearchParams } | null => {
let parsed: ReturnType<typeof parseMultiHostUri> | null = null;
for (const scheme of expectedSchemes) {
parsed = parseMultiHostUri(uriText, scheme);
if (parsed) {
break;
}
}
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, defaultPort);
if (!hostList.length) {
return null;
}
const primary = parseHostPort(hostList[0] || `localhost:${defaultPort}`, defaultPort);
return {
host: primary?.host || 'localhost',
port: primary?.port || defaultPort,
username: parsed.username,
password: parsed.password,
database: parsed.database || '',
params: parsed.params,
};
};
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();
const tlsValue = String(parsed.params.get('tls') || '').trim().toLowerCase();
const sslMode = tlsValue === 'true'
? 'required'
: tlsValue === 'skip-verify'
? 'skip-verify'
: tlsValue === 'preferred'
? 'preferred'
: 'disable';
return {
host: primary?.host || 'localhost',
port: primary?.port || mysqlDefaultPort,
user: parsed.username,
password: parsed.password,
database: parsed.database || '',
useSSL: sslMode !== 'disable',
sslMode,
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 === 'redis') {
const parsed = parseMultiHostUri(trimmedUri, 'redis') || parseMultiHostUri(trimmedUri, 'rediss');
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, 6379);
if (!hostList.length) {
return null;
}
const primary = parseHostPort(hostList[0] || 'localhost:6379', 6379);
const topologyParam = String(parsed.params.get('topology') || '').toLowerCase();
const dbText = String(parsed.database || '').trim().replace(/^\//, '');
const dbIndex = Number(dbText);
const isRediss = trimmedUri.toLowerCase().startsWith('rediss://');
const skipVerifyText = String(parsed.params.get('skip_verify') || '').trim().toLowerCase();
const skipVerify = skipVerifyText === '1' || skipVerifyText === 'true' || skipVerifyText === 'yes' || skipVerifyText === 'on';
return {
host: primary?.host || 'localhost',
port: primary?.port || 6379,
user: parsed.username || '',
password: parsed.password || '',
useSSL: isRediss,
sslMode: isRediss ? (skipVerify ? 'skip-verify' : 'required') : 'disable',
redisTopology: hostList.length > 1 || topologyParam === 'cluster' ? 'cluster' : 'single',
redisHosts: hostList.slice(1),
redisDB: Number.isFinite(dbIndex) && dbIndex >= 0 && dbIndex <= 15 ? Math.trunc(dbIndex) : 0,
};
}
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'));
const tlsText = String(parsed.params.get('tls') || parsed.params.get('ssl') || '').trim().toLowerCase();
const tlsInsecureText = String(parsed.params.get('tlsInsecure') || parsed.params.get('sslInsecure') || '').trim().toLowerCase();
const tlsEnabled = tlsText === '1' || tlsText === 'true' || tlsText === 'yes' || tlsText === 'on';
const tlsInsecure = tlsInsecureText === '1' || tlsInsecureText === 'true' || tlsInsecureText === 'yes' || tlsInsecureText === 'on';
return {
host: primary?.host || 'localhost',
port: primary?.port || 27017,
user: parsed.username,
password: parsed.password,
database: parsed.database || '',
useSSL: tlsEnabled,
sslMode: tlsEnabled ? (tlsInsecure ? 'skip-verify' : 'required') : 'disable',
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,
};
}
const singleHostSchemes = singleHostUriSchemesByType[type];
if (singleHostSchemes && singleHostSchemes.length > 0) {
const parsed = parseSingleHostUri(trimmedUri, singleHostSchemes, getDefaultPortByType(type));
if (!parsed) {
return null;
}
if (type === 'oracle' && !String(parsed.database || '').trim()) {
// Oracle 需要显式 service name避免 URI 解析后放过必填校验。
return null;
}
const parsedValues: Record<string, any> = {
host: parsed.host,
port: parsed.port,
user: parsed.username,
password: parsed.password,
database: parsed.database,
};
if (supportsSSLForType(type)) {
const normalizeBool = (raw: unknown) => {
const text = String(raw ?? '').trim().toLowerCase();
return text === '1' || text === 'true' || text === 'yes' || text === 'on';
};
if (type === 'postgres' || type === 'kingbase' || type === 'highgo' || type === 'vastbase') {
const sslMode = String(parsed.params.get('sslmode') || '').trim().toLowerCase();
if (sslMode) {
parsedValues.useSSL = sslMode !== 'disable' && sslMode !== 'false';
parsedValues.sslMode = sslMode === 'disable' || sslMode === 'false'
? 'disable'
: 'required';
}
} else if (type === 'sqlserver') {
const encrypt = String(parsed.params.get('encrypt') || '').trim().toLowerCase();
const trust = String(parsed.params.get('TrustServerCertificate') || parsed.params.get('trustservercertificate') || '').trim().toLowerCase();
const encrypted = encrypt === 'true' || encrypt === 'mandatory' || encrypt === 'yes' || encrypt === '1' || encrypt === 'strict';
if (encrypted) {
parsedValues.useSSL = true;
parsedValues.sslMode = trust === 'true' || trust === '1' || trust === 'yes' ? 'skip-verify' : 'required';
} else if (encrypt) {
parsedValues.useSSL = false;
parsedValues.sslMode = 'disable';
}
} else if (type === 'clickhouse') {
const secure = String(parsed.params.get('secure') || parsed.params.get('tls') || '').trim().toLowerCase();
const skipVerify = normalizeBool(parsed.params.get('skip_verify'));
if (secure) {
parsedValues.useSSL = normalizeBool(secure);
parsedValues.sslMode = skipVerify ? 'skip-verify' : (parsedValues.useSSL ? 'required' : 'disable');
}
} else if (type === 'dameng') {
const certPath = String(
parsed.params.get('SSL_CERT_PATH')
|| parsed.params.get('ssl_cert_path')
|| parsed.params.get('sslCertPath')
|| ''
).trim();
const keyPath = String(
parsed.params.get('SSL_KEY_PATH')
|| parsed.params.get('ssl_key_path')
|| parsed.params.get('sslKeyPath')
|| ''
).trim();
parsedValues.sslCertPath = certPath;
parsedValues.sslKeyPath = keyPath;
if (certPath || keyPath) {
parsedValues.useSSL = true;
parsedValues.sslMode = 'required';
}
} else if (type === 'oracle') {
const ssl = String(parsed.params.get('SSL') || parsed.params.get('ssl') || '').trim().toLowerCase();
const sslVerify = String(
parsed.params.get('SSL VERIFY')
|| parsed.params.get('ssl verify')
|| parsed.params.get('SSL_VERIFY')
|| parsed.params.get('ssl_verify')
|| ''
).trim().toLowerCase();
if (ssl) {
parsedValues.useSSL = normalizeBool(ssl);
if (!parsedValues.useSSL) {
parsedValues.sslMode = 'disable';
} else {
parsedValues.sslMode = normalizeBool(sslVerify || 'true') ? 'required' : 'skip-verify';
}
}
} else if (type === 'tdengine') {
const protocol = String(parsed.params.get('protocol') || '').trim().toLowerCase();
const skipVerify = normalizeBool(parsed.params.get('skip_verify'));
if (protocol === 'wss') {
parsedValues.useSSL = true;
parsedValues.sslMode = skipVerify ? 'skip-verify' : 'required';
} else if (protocol === 'ws') {
parsedValues.useSSL = false;
parsedValues.sslMode = 'disable';
}
}
};
return parsedValues;
}
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' ? 'doris' : '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';
}
if (dbType === 'redis') {
return 'redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster';
}
if (dbType === 'oracle') {
return 'oracle://user:pass@127.0.0.1:1521/ORCLPDB1';
}
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 (values.useSSL) {
const mode = String(values.sslMode || 'preferred').trim().toLowerCase();
if (mode === 'required') {
params.set('tls', 'true');
} else if (mode === 'skip-verify') {
params.set('tls', 'skip-verify');
} else {
params.set('tls', 'preferred');
}
}
if (Number.isFinite(timeout) && timeout > 0) {
params.set('timeout', String(timeout));
}
const dbPath = database ? `/${encodeURIComponent(database)}` : '/';
const query = params.toString();
const scheme = type === 'diros' ? 'doris' : 'mysql';
return `${scheme}://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`;
}
if (type === 'redis') {
const primary = toAddress(host, port, 6379);
const clusterHosts = values.redisTopology === 'cluster'
? normalizeAddressList(values.redisHosts, 6379)
: [];
const hosts = normalizeAddressList([primary, ...clusterHosts], 6379);
const params = new URLSearchParams();
if (hosts.length > 1 || values.redisTopology === 'cluster') {
params.set('topology', 'cluster');
}
const redisUser = String(values.user || '').trim();
const redisPassword = String(values.password || '');
let redisAuth = '';
if (redisUser || redisPassword) {
const encodedPassword = redisPassword ? encodeURIComponent(redisPassword) : '';
redisAuth = redisUser
? `${encodeURIComponent(redisUser)}${redisPassword ? `:${encodedPassword}` : ''}@`
: `:${encodedPassword}@`;
}
const redisDB = Number.isFinite(Number(values.redisDB))
? Math.max(0, Math.min(15, Math.trunc(Number(values.redisDB))))
: 0;
const dbPath = `/${redisDB}`;
if (values.useSSL) {
const mode = String(values.sslMode || 'preferred').trim().toLowerCase();
if (mode === 'skip-verify' || mode === 'preferred') {
params.set('skip_verify', 'true');
}
}
const query = params.toString();
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}://`;
}
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 (values.useSSL) {
const mode = String(values.sslMode || 'preferred').trim().toLowerCase();
params.set('tls', 'true');
if (mode === 'skip-verify' || mode === 'preferred') {
params.set('tlsInsecure', 'true');
} else {
params.delete('tlsInsecure');
}
}
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)}` : '';
const params = new URLSearchParams();
if (supportsSSLForType(type) && values.useSSL) {
const mode = String(values.sslMode || 'preferred').trim().toLowerCase();
if (type === 'postgres' || type === 'kingbase' || type === 'highgo' || type === 'vastbase') {
params.set('sslmode', 'require');
} else if (type === 'sqlserver') {
params.set('encrypt', 'true');
params.set('TrustServerCertificate', mode === 'skip-verify' || mode === 'preferred' ? 'true' : 'false');
} else if (type === 'clickhouse') {
params.set('secure', 'true');
if (mode === 'skip-verify' || mode === 'preferred') {
params.set('skip_verify', 'true');
}
} else if (type === 'dameng') {
const certPath = String(values.sslCertPath || '').trim();
const keyPath = String(values.sslKeyPath || '').trim();
if (certPath) params.set('SSL_CERT_PATH', certPath);
if (keyPath) params.set('SSL_KEY_PATH', keyPath);
} else if (type === 'oracle') {
params.set('SSL', 'TRUE');
params.set('SSL VERIFY', mode === 'required' ? 'TRUE' : 'FALSE');
} else if (type === 'tdengine') {
params.set('protocol', 'wss');
if (mode === 'skip-verify' || mode === 'preferred') {
params.set('skip_verify', 'true');
}
}
} else if (supportsSSLForType(type)) {
if (type === 'postgres' || type === 'kingbase' || type === 'highgo' || type === 'vastbase') {
params.set('sslmode', 'disable');
} else if (type === 'sqlserver') {
params.set('encrypt', 'disable');
params.set('TrustServerCertificate', 'true');
} else if (type === 'tdengine') {
params.set('protocol', 'ws');
}
}
const query = params.toString();
return `${scheme}://${encodedAuth}${toAddress(host, port, defaultPort)}${dbPath}${query ? `?${query}` : ''}`;
};
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 !== '已取消') {
message.error(`选择私钥文件失败: ${res?.message || '未知错误'}`);
}
} catch (e: any) {
message.error(`选择私钥文件失败: ${e?.message || String(e)}`);
} finally {
setSelectingSSHKey(false);
}
};
const handleSelectDatabaseFile = async () => {
if (selectingDbFile) {
return;
}
try {
setSelectingDbFile(true);
const currentPath = String(form.getFieldValue('host') || '').trim();
const res = await SelectDatabaseFile(currentPath, dbType);
if (res?.success) {
const data = res.data || {};
const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim();
if (selectedPath) {
form.setFieldValue('host', normalizeFileDbPath(selectedPath));
}
} else if (res?.message !== '已取消') {
message.error(`选择数据库文件失败: ${res?.message || '未知错误'}`);
}
} catch (e: any) {
message.error(`选择数据库文件失败: ${e?.message || String(e)}`);
} finally {
setSelectingDbFile(false);
}
};
useEffect(() => {
if (open) {
setLoading(false);
testInFlightRef.current = false;
if (testTimerRef.current !== null) {
window.clearTimeout(testTimerRef.current);
testTimerRef.current = null;
}
setTestResult(null); // Reset test result
setTestErrorLogOpen(false);
setDbList([]);
setRedisDbList([]);
setMongoMembers([]);
setUriFeedback(null);
setCustomIconType(undefined);
setCustomIconColor(undefined);
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 redisHosts = configType === 'redis' ? 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;
const redisIsCluster = String(config.topology || '').toLowerCase() === 'cluster' || redisHosts.length > 0;
const hasHttpTunnel = !!config.useHttpTunnel;
const hasProxy = !hasHttpTunnel && !!config.useProxy;
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,
useSSL: !!config.useSSL,
sslMode: config.sslMode || 'preferred',
sslCertPath: config.sslCertPath || '',
sslKeyPath: config.sslKeyPath || '',
useSSH: config.useSSH,
sshHost: config.ssh?.host,
sshPort: config.ssh?.port,
sshUser: config.ssh?.user,
sshPassword: config.ssh?.password,
sshKeyPath: config.ssh?.keyPath,
useProxy: hasProxy,
proxyType: config.proxy?.type || 'socks5',
proxyHost: config.proxy?.host,
proxyPort: config.proxy?.port,
proxyUser: config.proxy?.user,
proxyPassword: config.proxy?.password,
useHttpTunnel: hasHttpTunnel,
httpTunnelHost: config.httpTunnel?.host,
httpTunnelPort: config.httpTunnel?.port || 8080,
httpTunnelUser: config.httpTunnel?.user,
httpTunnelPassword: config.httpTunnel?.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,
redisTopology: redisIsCluster ? 'cluster' : 'single',
redisHosts: redisHosts,
mongoSrv: !!config.mongoSrv,
mongoReplicaSet: config.replicaSet || '',
mongoAuthSource: config.authSource || '',
mongoReadPreference: config.readPreference || 'primary',
mongoAuthMechanism: config.mongoAuthMechanism || '',
savePassword: config.savePassword !== false,
redisDB: Number.isFinite(Number(config.redisDB)) ? Number(config.redisDB) : 0,
mongoReplicaUser: config.mongoReplicaUser || '',
mongoReplicaPassword: config.mongoReplicaPassword || ''
});
setUseSSL(!!config.useSSL);
setCustomIconType(initialValues.iconType);
setCustomIconColor(initialValues.iconColor);
setUseSSH(config.useSSH || false);
setUseProxy(hasProxy);
setUseHttpTunnel(hasHttpTunnel);
setDbType(configType);
if (config.useSSL && supportsSSLForType(configType)) {
setActiveNetworkConfig('ssl');
} else if (config.useSSH) {
setActiveNetworkConfig('ssh');
} else if (hasProxy) {
setActiveNetworkConfig('proxy');
} else if (hasHttpTunnel) {
setActiveNetworkConfig('httpTunnel');
} else {
setActiveNetworkConfig('ssl');
}
// 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表
if (configType === 'redis') {
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
}
} else {
// Create mode: Start at step 1
setActiveConfigSection('basic');
setStep(1);
form.resetFields();
setUseSSL(false);
setUseSSH(false);
setUseProxy(false);
setUseHttpTunnel(false);
setDbType('mysql');
setActiveGroup(0);
setActiveConfigSection('basic');
setActiveNetworkConfig('ssl');
}
}
}, [open, initialValues]);
useEffect(() => {
return () => {
if (testTimerRef.current !== null) {
window.clearTimeout(testTimerRef.current);
testTimerRef.current = null;
}
};
}, []);
const handleOk = async () => {
try {
await form.validateFields();
const values = form.getFieldsValue(true);
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,
iconType: customIconType,
iconColor: customIconColor,
};
if (initialValues) {
updateConnection(newConn);
message.success('配置已更新(未连接)');
} else {
addConnection(newConn);
message.success('配置已保存(未连接)');
}
setLoading(false);
form.resetFields();
setUseSSL(false);
setUseSSH(false);
setUseProxy(false);
setUseHttpTunnel(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 withClientTimeout = async <T,>(promise: Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> => {
let timer: number | null = null;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = window.setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
}),
]);
} finally {
if (timer !== null) {
window.clearTimeout(timer);
}
}
};
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 {
await form.validateFields();
const values = form.getFieldsValue(true);
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);
const timeoutSecondsRaw = Number(values.timeout);
const timeoutSeconds = Number.isFinite(timeoutSecondsRaw) && timeoutSecondsRaw > 0
? Math.min(timeoutSecondsRaw, MAX_TIMEOUT_SECONDS)
: 30;
const rpcTimeoutMs = (timeoutSeconds + 5) * 1000;
// Use different API for Redis
const isRedisType = values.type === 'redis';
const res = await withClientTimeout(
isRedisType
? RedisConnect(config as any)
: TestConnection(config as any),
rpcTimeoutMs,
`连接测试超时(>${timeoutSeconds} 秒),请检查网络/代理/SSH配置后重试`
);
if (res.success) {
setTestResult({ type: 'success', message: res.message });
if (isRedisType) {
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
} else {
// Other databases: fetch database list
const dbRes = await withClientTimeout(
DBGetDatabases(config as any),
rpcTimeoutMs,
`连接成功但拉取数据库列表超时(>${timeoutSeconds} 秒)`
);
if (dbRes.success) {
const dbRows = Array.isArray(dbRes.data) ? dbRes.data : [];
const dbs = dbRows
.map((row: any) => row?.Database || row?.database)
.filter((name: any) => typeof name === 'string' && name.trim() !== '');
setDbList(dbs);
if (dbs.length === 0) {
message.warning(values.type === 'dameng'
? '连接成功,但未获取到可见 schema请检查当前账号权限或默认 schema 配置'
: '连接成功,但未获取到可见数据库列表');
}
} else {
setDbList([]);
message.warning(`连接成功,但获取数据库列表失败:${dbRes.message || '未知错误'}`);
}
}
} 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 {
await form.validateFields();
const values = form.getFieldsValue(true);
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);
const sslCapableType = supportsSSLForType(type);
// Redis 默认不展示用户名字段;若 URI 可解析则以 URI 为准覆盖 user
// 同时清理历史默认值 root避免 go-redis 发送 ACL AUTH(user, pass) 导致 WRONGPASS。
if (type === 'redis') {
if (parsedUriValues && Object.prototype.hasOwnProperty.call(parsedUriValues, 'user')) {
mergedValues.user = String((parsedUriValues as any).user || '');
} else if (String(mergedValues.user || '').trim() === 'root') {
mergedValues.user = '';
}
}
const sslModeRaw = String(mergedValues.sslMode || 'preferred').trim().toLowerCase();
const sslMode: 'preferred' | 'required' | 'skip-verify' | 'disable' = sslModeRaw === 'required'
? 'required'
: sslModeRaw === 'skip-verify'
? 'skip-verify'
: sslModeRaw === 'disable'
? 'disable'
: 'preferred';
const effectiveUseSSL = sslCapableType && !!mergedValues.useSSL;
const sslCertPath = sslCapableType ? String(mergedValues.sslCertPath || '').trim() : '';
const sslKeyPath = sslCapableType ? String(mergedValues.sslKeyPath || '').trim() : '';
if (type === 'dameng' && effectiveUseSSL && (!sslCertPath || !sslKeyPath)) {
throw new Error('达梦启用 SSL 时必须填写证书路径与私钥路径');
}
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' | 'cluster' | 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();
}
if (type === 'redis') {
const clusterNodes = mergedValues.redisTopology === 'cluster'
? normalizeAddressList(mergedValues.redisHosts, defaultPort)
: [];
const allHosts = normalizeAddressList([`${primaryHost}:${primaryPort}`, ...clusterNodes], defaultPort);
if (mergedValues.redisTopology === 'cluster' || allHosts.length > 1) {
hosts = allHosts;
topology = 'cluster';
} else {
topology = 'single';
}
mergedValues.redisDB = Number.isFinite(Number(mergedValues.redisDB))
? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB))))
: 0;
}
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 effectiveUseHttpTunnel = !isFileDbType && !!mergedValues.useHttpTunnel;
const effectiveUseProxy = !isFileDbType && !!mergedValues.useProxy && !effectiveUseHttpTunnel;
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 httpTunnelConfig: NonNullable<ConnectionConfig['httpTunnel']> = effectiveUseHttpTunnel ? {
host: String(mergedValues.httpTunnelHost || '').trim(),
port: Number(mergedValues.httpTunnelPort || 8080),
user: String(mergedValues.httpTunnelUser || '').trim(),
password: mergedValues.httpTunnelPassword || "",
} : {
host: '',
port: 8080,
user: '',
password: '',
};
if (effectiveUseHttpTunnel) {
if (!httpTunnelConfig.host) {
throw new Error('HTTP 隧道主机不能为空');
}
if (!Number.isFinite(httpTunnelConfig.port) || httpTunnelConfig.port <= 0 || httpTunnelConfig.port > 65535) {
throw new Error('HTTP 隧道端口必须在 1-65535 之间');
}
}
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 || "",
useSSL: effectiveUseSSL,
sslMode: effectiveUseSSL ? sslMode : 'disable',
sslCertPath: sslCertPath,
sslKeyPath: sslKeyPath,
useSSH: !!mergedValues.useSSH,
ssh: sshConfig,
useProxy: effectiveUseProxy,
proxy: proxyConfig,
useHttpTunnel: effectiveUseHttpTunnel,
httpTunnel: httpTunnelConfig,
driver: mergedValues.driver,
dsn: mergedValues.dsn,
timeout: Number(mergedValues.timeout || 30),
redisDB: Number.isFinite(Number(mergedValues.redisDB))
? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB))))
: 0,
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 = (type: string) => {
const normalized = normalizeDriverType(type);
const snapshot = driverStatusMap[normalized];
if (snapshot && !snapshot.connectable) {
const driverName = snapshot.name || type;
const reason = snapshot.message || `${driverName} 驱动未安装启用,请先在驱动管理中安装`;
setTypeSelectWarning({ driverName, reason });
return;
}
setTypeSelectWarning(null);
setDbType(type);
form.setFieldsValue({ type: type });
const defaultPort = getDefaultPortByType(type);
if (isFileDatabaseType(type)) {
setUseSSL(false);
setUseSSH(false);
setUseProxy(false);
setUseHttpTunnel(false);
form.setFieldsValue({
host: '',
port: 0,
user: '',
password: '',
database: '',
useSSL: false,
sslMode: 'preferred',
sslCertPath: '',
sslKeyPath: '',
useSSH: false,
sshHost: '',
sshPort: 22,
sshUser: '',
sshPassword: '',
sshKeyPath: '',
useProxy: false,
proxyType: 'socks5',
proxyHost: '',
proxyPort: 1080,
proxyUser: '',
proxyPassword: '',
useHttpTunnel: false,
httpTunnelHost: '',
httpTunnelPort: 8080,
httpTunnelUser: '',
httpTunnelPassword: '',
mysqlTopology: 'single',
redisTopology: 'single',
mongoTopology: 'single',
mongoSrv: false,
mongoReadPreference: 'primary',
mongoReplicaSet: '',
mongoAuthSource: '',
mongoAuthMechanism: '',
savePassword: true,
mysqlReplicaHosts: [],
redisHosts: [],
mongoHosts: [],
mysqlReplicaUser: '',
mysqlReplicaPassword: '',
mongoReplicaUser: '',
mongoReplicaPassword: '',
redisDB: 0,
});
} else if (type !== 'custom') {
const defaultUser = type === 'clickhouse'
? 'default'
: type === 'redis'
? ''
: 'root';
const sslCapableType = supportsSSLForType(type);
setUseSSL(false);
setUseHttpTunnel(false);
form.setFieldsValue({
user: defaultUser,
database: '',
port: defaultPort,
useSSL: sslCapableType ? false : undefined,
sslMode: sslCapableType ? 'preferred' : undefined,
sslCertPath: sslCapableType ? '' : undefined,
sslKeyPath: sslCapableType ? '' : undefined,
useHttpTunnel: false,
httpTunnelHost: '',
httpTunnelPort: 8080,
httpTunnelUser: '',
httpTunnelPassword: '',
mysqlTopology: 'single',
redisTopology: 'single',
mongoTopology: 'single',
mongoSrv: false,
mongoReadPreference: 'primary',
mongoReplicaSet: '',
mongoAuthSource: '',
mongoAuthMechanism: '',
savePassword: true,
mysqlReplicaHosts: [],
redisHosts: [],
mongoHosts: [],
mysqlReplicaUser: '',
mysqlReplicaPassword: '',
mongoReplicaUser: '',
mongoReplicaPassword: '',
redisDB: 0,
});
}
setMongoMembers([]);
setStep(2);
if (!driverStatusLoaded || !snapshot) {
void refreshDriverStatus();
}
};
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: getDbIcon('mysql', undefined, 36) },
{ key: 'mariadb', name: 'MariaDB', icon: getDbIcon('mariadb', undefined, 36) },
{ key: 'diros', name: 'Doris', icon: getDbIcon('diros', undefined, 36) },
{ key: 'sphinx', name: 'Sphinx', icon: getDbIcon('sphinx', undefined, 36) },
{ key: 'clickhouse', name: 'ClickHouse', icon: getDbIcon('clickhouse', undefined, 36) },
{ key: 'postgres', name: 'PostgreSQL', icon: getDbIcon('postgres', undefined, 36) },
{ key: 'sqlserver', name: 'SQL Server', icon: getDbIcon('sqlserver', undefined, 36) },
{ key: 'sqlite', name: 'SQLite', icon: getDbIcon('sqlite', undefined, 36) },
{ key: 'duckdb', name: 'DuckDB', icon: getDbIcon('duckdb', undefined, 36) },
{ key: 'oracle', name: 'Oracle', icon: getDbIcon('oracle', undefined, 36) },
]},
{ label: '国产数据库', items: [
{ key: 'dameng', name: 'Dameng (达梦)', icon: getDbIcon('dameng', undefined, 36) },
{ key: 'kingbase', name: 'Kingbase (人大金仓)', icon: getDbIcon('kingbase', undefined, 36) },
{ key: 'highgo', name: 'HighGo (瀚高)', icon: getDbIcon('highgo', undefined, 36) },
{ key: 'vastbase', name: 'Vastbase (海量)', icon: getDbIcon('vastbase', undefined, 36) },
]},
{ label: 'NoSQL', items: [
{ key: 'mongodb', name: 'MongoDB', icon: getDbIcon('mongodb', undefined, 36) },
{ key: 'redis', name: 'Redis', icon: getDbIcon('redis', undefined, 36) },
]},
{ label: '时序数据库', items: [
{ key: 'tdengine', name: 'TDengine', icon: getDbIcon('tdengine', undefined, 36) },
]},
{ label: '其他', items: [
{ key: 'custom', name: 'Custom (自定义)', icon: getDbIcon('custom', undefined, 36) },
]},
];
const dbTypes = dbTypeGroups.flatMap(g => g.items);
const renderStep1 = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, height: '100%' }}>
<div style={{ ...modalInnerSectionStyle, paddingBottom: 12 }}>
<div style={{ marginBottom: 12, color: darkMode ? '#f5f7ff' : '#162033', fontSize: 14, fontWeight: 700 }}></div>
<div style={modalMutedTextStyle}></div>
</div>
{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={{ ...modalInnerSectionStyle, display: 'flex', flex: 1, minHeight: 0, padding: 12 }}>
{/* 左侧分类导航 */}
<div style={{ width: 120, borderRight: `1px solid ${step1SidebarDividerColor}`, paddingRight: 8, flexShrink: 0, overflowY: 'auto' }}>
{dbTypeGroups.map((group, idx) => (
<div
key={group.label}
onClick={() => setActiveGroup(idx)}
style={{
padding: '10px 12px',
cursor: 'pointer',
borderRadius: 6,
marginBottom: 4,
background: activeGroup === idx ? step1SidebarActiveBg : 'transparent',
color: activeGroup === idx ? step1SidebarActiveColor : undefined,
fontWeight: activeGroup === idx ? 500 : 400,
transition: 'all 0.2s',
fontSize: 13,
}}
>
{group.label}
</div>
))}
</div>
{/* 右侧数据源卡片 */}
<div style={{ flex: 1, minHeight: 0, 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 = () => {
const baseInfoSection = (
<div style={modalInnerSectionStyle}>
<div style={{ marginBottom: 12, color: darkMode ? '#f5f7ff' : '#162033', fontSize: 14, fontWeight: 700 }}></div>
<div style={{ ...modalMutedTextStyle, marginBottom: 16 }}></div>
<Form.Item name="name" label="连接名称">
<Input placeholder="例如:本地测试库" />
</Form.Item>
{!isCustom && (
<>
<Form.Item
name="uri"
label="连接 URI可复制粘贴"
help="支持从参数生成、复制到剪贴板,或粘贴后一键解析回填参数"
>
<Input.TextArea rows={3} placeholder={getUriPlaceholder()} />
</Form.Item>
<Space size={8} style={{ marginBottom: uriFeedback ? 12 : 16 }} wrap>
<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: 16 }}
/>
)}
</>
)}
{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={4} placeholder="例如: user:pass@tcp(localhost:3306)/dbname?charset=utf8" />
</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 ? '文件路径 (绝对路径)' : '主机地址 (Host)'}
rules={[createUriAwareRequiredRule('请输入地址/路径')]}
style={{ marginBottom: 0 }}
>
<Input
placeholder={isFileDb ? (dbType === 'duckdb' ? '/path/to/db.duckdb' : '/path/to/db.sqlite') : 'localhost'}
/>
</Form.Item>
{isFileDb ? (
<Form.Item label=" " style={{ marginBottom: 0 }}>
<Button style={{ width: '100%' }} onClick={handleSelectDatabaseFile} loading={selectingDbFile}>
...
</Button>
</Form.Item>
) : (
<Form.Item
name="port"
label="端口 (Port)"
rules={[createUriAwareRequiredRule('请输入端口号', (value) => Number(value) > 0)]}
style={{ marginBottom: 0 }}
>
<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 === 'oracle' && (
<Form.Item
name="database"
label="服务名 (Service Name)"
rules={[createUriAwareRequiredRule('请输入 Oracle 服务名(例如 ORCLPDB1')]}
help="请填写监听器注册的 SERVICE_NAME不是用户名。例如ORCLPDB1"
>
<Input placeholder="例如ORCLPDB1" />
</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: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item name="mysqlReplicaUser" label="从库用户名(可选)" style={{ marginBottom: 0 }}>
<Input placeholder="留空沿用主库用户名" />
</Form.Item>
<Form.Item name="mysqlReplicaPassword" label="从库密码(可选)" style={{ marginBottom: 0 }}>
<Input.Password placeholder="留空沿用主库密码" />
</Form.Item>
</div>
</>
)}
</>
)}
{dbType === 'mongodb' && (
<>
<Form.Item name="mongoTopology" label="连接模式">
<Select
options={[
{ value: 'single', label: '单机模式' },
{ value: 'replica', label: '副本集 / 多节点' },
]}
/>
</Form.Item>
<Form.Item name="mongoSrv" valuePropName="checked" style={{ marginTop: -6 }}>
<Checkbox>使 SRVmongodb+srv</Checkbox>
</Form.Item>
{mongoSrv && useSSH && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
message="SRV 与 SSH 隧道同时启用时,可能依赖本地 DNS 解析能力"
/>
)}
{mongoTopology === 'replica' && (
<>
<Form.Item name="mongoHosts" label={mongoSrv ? '附加 SRV 主机(可选)' : '附加节点地址'} help={mongoSrv ? '可输入多个候选主机名格式host若留空则仅使用上方主机。' : '可输入多个节点地址格式host:port回车确认'}>
<Select mode="tags" placeholder={mongoSrv ? '例如cluster-a.example.com、cluster-b.example.com' : '例如10.10.0.12:27017、10.10.0.13:27017'} tokenSeparators={[',', ';', ' ']} />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item name="mongoReplicaSet" label="副本集名称(可选)" style={{ marginBottom: 0 }}>
<Input placeholder="例如rs0" />
</Form.Item>
<Form.Item name="mongoReplicaUser" label="副本集用户名(可选)" style={{ marginBottom: 0 }}>
<Input placeholder="留空沿用主用户名" />
</Form.Item>
</div>
<Form.Item name="mongoReplicaPassword" label="副本集密码(可选)" style={{ marginBottom: 0 }}>
<Input.Password placeholder="留空沿用主密码" />
</Form.Item>
<Space size={8} style={{ marginTop: 12, marginBottom: 12 }}>
<Button onClick={handleDiscoverMongoMembers} loading={discoveringMembers}></Button>
</Space>
{mongoMembers.length > 0 && (
<Table
size="small"
rowKey={(record) => record.host}
pagination={false}
dataSource={mongoMembers}
style={{ marginBottom: 12 }}
columns={[
{ title: 'Host', dataIndex: 'host', width: '48%' },
{
title: '角色',
dataIndex: 'role',
width: '32%',
render: (value: string, record: MongoMemberInfo) => (
<Tag color={record.isSelf ? 'blue' : 'default'}>{value || 'UNKNOWN'}</Tag>
),
},
{
title: '健康',
dataIndex: 'healthy',
width: '20%',
render: (value: boolean) => (
<Tag color={value ? 'success' : 'error'}>{value ? '正常' : '异常'}</Tag>
),
},
]}
/>
)}
</>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item name="mongoAuthSource" label="认证库 (authSource)" style={{ marginBottom: 0 }}>
<Input placeholder="默认使用 database 或 admin" />
</Form.Item>
<Form.Item name="mongoReadPreference" label="读偏好 (readPreference)" style={{ marginBottom: 0 }}>
<Select
options={[
{ value: 'primary', label: 'primary' },
{ value: 'primaryPreferred', label: 'primaryPreferred' },
{ value: 'secondary', label: 'secondary' },
{ value: 'secondaryPreferred', label: 'secondaryPreferred' },
{ value: 'nearest', label: 'nearest' },
]}
/>
</Form.Item>
</div>
</>
)}
{isRedis && (
<>
<Form.Item name="redisTopology" label="连接模式">
<Select
options={[
{ value: 'single', label: '单机模式' },
{ value: 'cluster', label: '集群模式Redis Cluster' },
]}
/>
</Form.Item>
{redisTopology === 'cluster' && (
<Form.Item
name="redisHosts"
label="集群附加节点地址"
help="主节点使用上方主机地址这里填写其他种子节点格式host:port"
>
<Select mode="tags" placeholder="例如10.10.0.12:6379、10.10.0.13:6379" tokenSeparators={[',', ';', ' ']} />
</Form.Item>
)}
<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>
</>
)}
{!isFileDb && !isRedis && (
<div style={{ display: 'grid', gridTemplateColumns: dbType === 'mongodb' ? 'minmax(0, 1fr) minmax(0, 1fr) 180px' : 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item
name="user"
label="用户名"
rules={dbType === 'mongodb' ? [] : [createUriAwareRequiredRule('请输入用户名')]}
style={{ marginBottom: 0 }}
>
<Input />
</Form.Item>
<Form.Item name="password" label="密码" style={{ marginBottom: 0 }}>
<Input.Password />
</Form.Item>
{dbType === 'mongodb' && (
<Form.Item name="mongoAuthMechanism" label="验证方式" style={{ marginBottom: 0 }}>
<Select
allowClear
placeholder="自动协商"
options={[
{ value: 'NONE', label: '无认证 (None)' },
{ 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: 12, marginBottom: 0 }}>
<Checkbox></Checkbox>
</Form.Item>
)}
{!isFileDb && !isRedis && (
<Form.Item name="includeDatabases" label="显示数据库 (留空显示全部)" help="连接测试成功后可选择" style={{ marginTop: 12, marginBottom: 0 }}>
<Select mode="multiple" placeholder="选择显示的数据库" allowClear>
{dbList.map(db => <Select.Option key={db} value={db}>{db}</Select.Option>)}
</Select>
</Form.Item>
)}
</>
)}
</div>
);
const networkSecuritySection = !isFileDb ? (() => {
const networkItems: Array<{
key: 'ssl' | 'ssh' | 'proxy' | 'httpTunnel';
title: string;
description: string;
enabled: boolean;
}> = [
...(isSSLType ? [{ key: 'ssl' as const, title: 'SSL/TLS', description: '加密与证书校验', enabled: useSSL }] : []),
{ key: 'ssh', title: 'SSH 隧道', description: '跳板机 / 堡垒机转发', enabled: useSSH },
{ key: 'proxy', title: '代理', description: 'SOCKS5 / HTTP CONNECT', enabled: useProxy },
{ key: 'httpTunnel', title: 'HTTP 隧道', description: '独立 HTTP CONNECT 路由', enabled: useHttpTunnel },
];
const resolvedNetworkConfig = networkItems.some((item) => item.key === activeNetworkConfig)
? activeNetworkConfig
: networkItems[0]?.key || 'ssh';
const renderNetworkPanel = () => {
if (resolvedNetworkConfig === 'ssl') {
return (
<div style={{ ...modalInnerSectionStyle, padding: 14 }}>
<div style={{ marginBottom: 8, color: darkMode ? '#f5f7ff' : '#162033', fontSize: 14, fontWeight: 700 }}>SSL/TLS</div>
<div style={{ ...modalMutedTextStyle, marginBottom: 14 }}>访</div>
{!useSSL ? (
<div style={{ ...modalMutedTextStyle, padding: '10px 12px', borderRadius: 12, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.04)' }}>
SSL/TLS
</div>
) : (
<div style={tunnelSectionStyle}>
<Form.Item name="sslMode" label="SSL 模式" rules={[{ required: true, message: '请选择 SSL 模式' }]} style={{ marginBottom: 8 }}>
<Select
options={[
{ value: 'preferred', label: 'Preferred优先 SSL推荐' },
{ value: 'required', label: 'Required必须 SSL校验证书' },
{ value: 'skip-verify', label: 'Skip Verify必须 SSL跳过证书校验' },
]}
/>
</Form.Item>
{dbType === 'dameng' && (
<>
<Form.Item name="sslCertPath" label="客户端证书路径 (SSL_CERT_PATH)" rules={[{ required: true, message: '达梦 SSL 需要证书路径' }]} style={{ marginBottom: 8 }}>
<Input placeholder="例如: C:\certs\client-cert.pem" />
</Form.Item>
<Form.Item name="sslKeyPath" label="客户端私钥路径 (SSL_KEY_PATH)" rules={[{ required: true, message: '达梦 SSL 需要私钥路径' }]} style={{ marginBottom: 8 }}>
<Input placeholder="例如: C:\certs\client-key.pem" />
</Form.Item>
</>
)}
<Text type="secondary" style={{ fontSize: 12 }}>{sslHintText}</Text>
</div>
)}
</div>
);
}
if (resolvedNetworkConfig === 'ssh') {
return (
<div style={{ ...modalInnerSectionStyle, padding: 14 }}>
<div style={{ marginBottom: 8, color: darkMode ? '#f5f7ff' : '#162033', fontSize: 14, fontWeight: 700 }}>SSH </div>
<div style={{ ...modalMutedTextStyle, marginBottom: 14 }}></div>
{!useSSH ? (
<div style={{ ...modalMutedTextStyle, padding: '10px 12px', borderRadius: 12, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.04)' }}>
SSH
</div>
) : (
<div style={tunnelSectionStyle}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 120px', 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: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', 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>
)}
</div>
);
}
if (resolvedNetworkConfig === 'proxy') {
return (
<div style={{ ...modalInnerSectionStyle, padding: 14 }}>
<div style={{ marginBottom: 8, color: darkMode ? '#f5f7ff' : '#162033', fontSize: 14, fontWeight: 700 }}></div>
<div style={{ ...modalMutedTextStyle, marginBottom: 14 }}></div>
{!useProxy ? (
<div style={{ ...modalMutedTextStyle, padding: '10px 12px', borderRadius: 12, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.04)' }}>
</div>
) : (
<div style={tunnelSectionStyle}>
<Form.Item name="proxyHost" label="代理主机" rules={[{ required: useProxy, message: '请输入代理主机' }]}>
<Input placeholder="例如: 127.0.0.1 或 proxy.company.com" />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '180px 120px', gap: 16 }}>
<Form.Item name="proxyType" label="代理类型" rules={[{ required: useProxy, message: '请选择代理类型' }]} style={{ marginBottom: 0 }}>
<Select options={[
{ value: 'socks5', label: 'SOCKS5' },
{ value: 'http', label: 'HTTP CONNECT' },
]} />
</Form.Item>
<Form.Item name="proxyPort" label="端口" rules={[{ required: useProxy, message: '请输入代理端口' }]} style={{ marginBottom: 0 }}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', 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>
)}
</div>
);
}
return (
<div style={{ ...modalInnerSectionStyle, padding: 14 }}>
<div style={{ marginBottom: 8, color: darkMode ? '#f5f7ff' : '#162033', fontSize: 14, fontWeight: 700 }}>HTTP </div>
<div style={{ ...modalMutedTextStyle, marginBottom: 14 }}> HTTP CONNECT </div>
{!useHttpTunnel ? (
<div style={{ ...modalMutedTextStyle, padding: '10px 12px', borderRadius: 12, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.04)' }}>
HTTP
</div>
) : (
<div style={tunnelSectionStyle}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 120px', gap: 16 }}>
<Form.Item name="httpTunnelHost" label="隧道主机" rules={[{ required: useHttpTunnel, message: '请输入隧道主机' }]} style={{ flex: 1 }}>
<Input placeholder="例如: tunnel.company.com 或 127.0.0.1" />
</Form.Item>
<Form.Item name="httpTunnelPort" label="端口" rules={[{ required: useHttpTunnel, message: '请输入隧道端口' }]} style={{ width: 120 }}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item name="httpTunnelUser" label="隧道用户名(可选)" style={{ flex: 1 }}>
<Input placeholder="留空表示无认证" />
</Form.Item>
<Form.Item name="httpTunnelPassword" label="隧道密码(可选)" style={{ flex: 1 }}>
<Input.Password placeholder="留空表示无认证" />
</Form.Item>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>使 HTTP CONNECT </Text>
</div>
)}
</div>
);
};
return (
<div style={modalInnerSectionStyle}>
<div style={{ marginBottom: 12, color: darkMode ? '#f5f7ff' : '#162033', fontSize: 14, fontWeight: 700 }}></div>
<div style={{ ...modalMutedTextStyle, marginBottom: 16 }}></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12, marginBottom: 16 }}>
{networkItems.map((item) => {
const active = item.key === resolvedNetworkConfig;
const activeColor = darkMode ? '#ffd666' : '#1677ff';
return (
<div
key={item.key}
role="button"
tabIndex={0}
onClick={() => setActiveNetworkConfig(item.key)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setActiveNetworkConfig(item.key);
}
}}
style={{
...getConnectionOptionCardStyle(item.enabled),
borderColor: active
? (darkMode ? 'rgba(255,214,102,0.46)' : 'rgba(24,144,255,0.36)')
: 'transparent',
background: active
? (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.14) 0%, rgba(255,214,102,0.08) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.12) 0%, rgba(24,144,255,0.06) 100%)')
: getConnectionOptionCardStyle(item.enabled).background,
boxShadow: active
? (darkMode ? '0 0 0 1px rgba(255,214,102,0.18) inset, 0 12px 26px rgba(0,0,0,0.16)' : '0 0 0 1px rgba(24,144,255,0.14) inset, 0 12px 22px rgba(24,144,255,0.10)')
: 'none',
cursor: 'pointer',
outline: 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div style={{ width: 8, height: 8, marginTop: 8, borderRadius: 999, background: active ? activeColor : 'transparent', border: active ? 'none' : (darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(16,24,40,0.12)'), flexShrink: 0 }} />
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, minWidth: 0, flex: 1 }}>
<Form.Item name={item.key === 'ssl' ? 'useSSL' : item.key === 'ssh' ? 'useSSH' : item.key === 'proxy' ? 'useProxy' : 'useHttpTunnel'} valuePropName="checked" noStyle>
<Checkbox />
</Form.Item>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontSize: 14, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>{item.title}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{active && (
<span style={{ padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 700, color: activeColor, background: darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.12)' }}>
</span>
)}
<span style={{ fontSize: 11, fontWeight: 700, color: item.enabled ? activeColor : (darkMode ? 'rgba(255,255,255,0.38)' : 'rgba(16,24,40,0.36)') }}>
{item.enabled ? '已启用' : '未启用'}
</span>
</div>
</div>
<div style={{ marginTop: 4, ...modalMutedTextStyle, color: active ? (darkMode ? 'rgba(255,255,255,0.72)' : 'rgba(22,32,51,0.68)') : modalMutedTextStyle.color }}>
{item.description}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
<div style={{ marginBottom: 16 }}>
{renderNetworkPanel()}
</div>
<div style={{ ...modalInnerSectionStyle, padding: 12 }}>
<div style={{ marginBottom: 10, color: darkMode ? '#f5f7ff' : '#162033', fontSize: 13, fontWeight: 700 }}></div>
<Form.Item name="timeout" label="连接超时 (秒)" help="数据库连接超时时间,默认 30 秒" rules={[{ type: 'number', min: 1, max: 300, message: '超时时间范围: 1-300 秒' }]} style={{ marginBottom: 0 }}>
<InputNumber style={{ width: '100%' }} min={1} max={300} placeholder="30" />
</Form.Item>
</div>
</div>
);
})() : null;
return (
<Form
form={form}
layout="vertical"
initialValues={{
type: 'mysql',
host: 'localhost',
port: 3306,
database: '',
user: 'root',
useSSL: false,
sslMode: 'preferred',
sslCertPath: '',
sslKeyPath: '',
useSSH: false,
sshPort: 22,
useProxy: false,
proxyType: 'socks5',
proxyPort: 1080,
useHttpTunnel: false,
httpTunnelPort: 8080,
timeout: 30,
uri: '',
mysqlTopology: 'single',
redisTopology: 'single',
mongoTopology: 'single',
mongoSrv: false,
mongoReadPreference: 'primary',
mongoAuthMechanism: '',
savePassword: true,
mysqlReplicaHosts: [],
redisHosts: [],
mongoHosts: [],
mysqlReplicaUser: '',
mysqlReplicaPassword: '',
mongoReplicaUser: '',
mongoReplicaPassword: '',
redisDB: 0,
}}
onValuesChange={(changed) => {
if (testResult) {
setTestResult(null);
setTestErrorLogOpen(false);
}
if (changed.uri !== undefined || changed.type !== undefined) {
setUriFeedback(null);
}
if (changed.useSSL !== undefined) {
setUseSSL(changed.useSSL);
if (changed.useSSL) setActiveNetworkConfig('ssl');
}
if (changed.useSSH !== undefined) {
setUseSSH(changed.useSSH);
if (changed.useSSH) setActiveNetworkConfig('ssh');
}
if (changed.useProxy !== undefined) {
const enabledProxy = !!changed.useProxy;
setUseProxy(enabledProxy);
if (enabledProxy) setActiveNetworkConfig('proxy');
if (enabledProxy && form.getFieldValue('useHttpTunnel')) {
form.setFieldValue('useHttpTunnel', false);
setUseHttpTunnel(false);
}
}
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);
}
}
}
if (changed.useHttpTunnel !== undefined) {
const enabledHttpTunnel = !!changed.useHttpTunnel;
setUseHttpTunnel(enabledHttpTunnel);
if (enabledHttpTunnel) setActiveNetworkConfig('httpTunnel');
if (enabledHttpTunnel && form.getFieldValue('useProxy')) {
form.setFieldValue('useProxy', false);
setUseProxy(false);
}
if (enabledHttpTunnel) {
const currentPort = Number(form.getFieldValue('httpTunnelPort') || 0);
if (!currentPort || currentPort <= 0) {
form.setFieldValue('httpTunnelPort', 8080);
}
}
}
if (changed.type !== undefined) setDbType(changed.type);
if (changed.redisTopology !== undefined) {
const supportedDbs = Array.from({ length: 16 }, (_, i) => i);
setRedisDbList(supportedDbs);
const selectedDbsRaw = form.getFieldValue('includeRedisDatabases');
const selectedDbs = Array.isArray(selectedDbsRaw) ? selectedDbsRaw.map((entry: any) => Number(entry)) : [];
const validDbs = selectedDbs
.filter((entry: number) => Number.isFinite(entry))
.map((entry: number) => Math.trunc(entry))
.filter((entry: number) => supportedDbs.includes(entry));
form.setFieldValue('includeRedisDatabases', validDbs.length > 0 ? validDbs : undefined);
}
if (
changed.type !== undefined
|| changed.host !== undefined
|| changed.port !== undefined
|| changed.mongoHosts !== undefined
|| changed.mongoTopology !== undefined
|| changed.mongoSrv !== undefined
) {
setMongoMembers([]);
}
}}
>
<Form.Item name="type" hidden><Input /></Form.Item>
{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>
)}
/>
)}
{(() => {
const sectionItems: Array<{ key: 'basic' | 'network' | 'appearance'; title: string; description: string; icon: React.ReactNode }> = [
{ key: 'basic', title: '基础信息', description: '名称、地址、认证、URI 与数据库范围', icon: <DatabaseOutlined /> },
...(!isCustom && !isFileDb ? [{ key: 'network' as const, title: '网络与安全', description: 'SSL、SSH、代理与高级连接', icon: <CloudOutlined /> }] : []),
{ key: 'appearance', title: '外观', description: '自定义图标与颜色', icon: <BgColorsOutlined /> },
];
const resolvedSection = sectionItems.some((item) => item.key === activeConfigSection)
? activeConfigSection
: sectionItems[0]?.key || 'basic';
const effectiveIconType = customIconType || dbType;
const effectiveIconColor = customIconColor || getDbDefaultColor(effectiveIconType);
const appearanceSection = (
<div style={{ display: 'grid', gap: 18 }}>
<div style={{ ...modalInnerSectionStyle, padding: 16 }}>
<div style={{ marginBottom: 12, fontSize: 13, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}></div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{DB_ICON_TYPES.map((iconKey) => {
const isActive = effectiveIconType === iconKey;
return (
<button
key={iconKey}
type="button"
title={getDbIconLabel(iconKey)}
onClick={() => setCustomIconType(iconKey === dbType ? undefined : iconKey)}
style={{
width: 44, height: 44, borderRadius: 10,
display: 'grid', placeItems: 'center',
border: `2px solid ${isActive ? effectiveIconColor : (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)')}`,
background: isActive
? (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(24,144,255,0.06)')
: 'transparent',
cursor: 'pointer',
transition: 'all 120ms ease',
}}
>
{getDbIcon(iconKey, isActive ? effectiveIconColor : undefined, 22)}
</button>
);
})}
</div>
<div style={{ marginTop: 6, fontSize: 11, color: darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.35)' }}>
{getDbIconLabel(effectiveIconType)}
</div>
</div>
<div style={{ ...modalInnerSectionStyle, padding: 16 }}>
<div style={{ marginBottom: 12, fontSize: 13, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}></div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
{PRESET_ICON_COLORS.map((presetColor) => {
const isActive = effectiveIconColor === presetColor;
return (
<button
key={presetColor}
type="button"
onClick={() => setCustomIconColor(presetColor === getDbDefaultColor(effectiveIconType) ? undefined : presetColor)}
style={{
width: 28, height: 28, borderRadius: 8,
background: presetColor,
border: isActive ? `2.5px solid ${darkMode ? '#fff' : '#162033'}` : '2px solid transparent',
cursor: 'pointer',
transition: 'all 120ms ease',
boxShadow: isActive ? `0 0 0 2px ${presetColor}40` : 'none',
}}
/>
);
})}
<input
type="color"
value={effectiveIconColor}
onChange={(e) => setCustomIconColor(e.target.value === getDbDefaultColor(effectiveIconType) ? undefined : e.target.value)}
title="自定义颜色"
style={{ width: 28, height: 28, border: 'none', padding: 0, cursor: 'pointer', borderRadius: 6, background: 'transparent' }}
/>
</div>
</div>
<div style={{ ...modalInnerSectionStyle, padding: 16, display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}></div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{getDbIcon(effectiveIconType, effectiveIconColor, 24)}
<span style={{ fontSize: 14, color: darkMode ? '#e0e0e0' : '#333' }}>{form.getFieldValue('name') || '连接名称'}</span>
</div>
{(customIconType || customIconColor) && (
<Button size="small" type="link" onClick={() => { setCustomIconType(undefined); setCustomIconColor(undefined); }}>
</Button>
)}
</div>
</div>
);
const currentSectionContent = resolvedSection === 'basic'
? baseInfoSection
: resolvedSection === 'appearance'
? appearanceSection
: networkSecuritySection;
if (sectionItems.length <= 1) {
return currentSectionContent;
}
return (
<div style={{ display: 'grid', gridTemplateColumns: '220px minmax(0, 1fr)', gap: 18, alignItems: 'start' }}>
<div style={{ ...modalInnerSectionStyle, padding: 12, position: 'sticky', top: 0 }}>
<div style={{ marginBottom: 12, color: darkMode ? '#f5f7ff' : '#162033', fontSize: 13, fontWeight: 700, letterSpacing: 0.2 }}></div>
<div style={{ display: 'grid', gap: 10 }}>
{sectionItems.map((item) => {
const active = item.key === resolvedSection;
return (
<button
key={item.key}
type="button"
onClick={() => setActiveConfigSection(item.key)}
style={{
textAlign: 'left',
padding: '12px 12px 12px 14px',
borderRadius: 14,
border: `1px solid ${active
? (darkMode ? 'rgba(255,214,102,0.3)' : 'rgba(24,144,255,0.24)')
: (darkMode ? 'rgba(255,255,255,0.045)' : 'rgba(16,24,40,0.055)')}`,
background: active
? (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.12) 0%, rgba(255,214,102,0.06) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.10) 0%, rgba(24,144,255,0.05) 100%)')
: (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.7)'),
color: active
? (darkMode ? '#f5f7ff' : '#162033')
: (darkMode ? 'rgba(255,255,255,0.76)' : '#3f4b5e'),
cursor: 'pointer',
transition: 'all 120ms ease',
boxShadow: active
? (darkMode ? '0 10px 24px rgba(0,0,0,0.18)' : '0 10px 22px rgba(24,144,255,0.08)')
: 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div style={{ width: 30, height: 30, borderRadius: 10, display: 'grid', placeItems: 'center', flexShrink: 0, background: active
? (darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.14)')
: (darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(16,24,40,0.05)'), color: active
? (darkMode ? '#ffd666' : '#1677ff')
: (darkMode ? 'rgba(255,255,255,0.55)' : '#627089') }}>
{item.icon}
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontSize: 14, fontWeight: 700 }}>{item.title}</span>
<span style={{ width: 8, height: 8, borderRadius: 999, background: active ? (darkMode ? '#ffd666' : '#1677ff') : 'transparent', border: active ? 'none' : (darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(16,24,40,0.12)') }} />
</div>
<div style={{ marginTop: 5, fontSize: 12, lineHeight: 1.55, color: active
? (darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(22,32,51,0.68)')
: (darkMode ? 'rgba(255,255,255,0.42)' : 'rgba(63,75,94,0.62)') }}>
{item.description}
</div>
</div>
</div>
</button>
);
})}
</div>
</div>
<div style={{ minWidth: 0 }}>
{currentSectionContent}
</div>
</div>
);
})()}
</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, padding: '4px 2px 0' }}>
<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 renderConnectionModalTitle(<AppstoreOutlined />, '选择数据源类型', '按数据库、中间件或文件类型快速进入对应的连接配置流程。');
}
const typeName = dbTypes.find(t => t.key === dbType)?.name || dbType;
return initialValues
? renderConnectionModalTitle(<EditOutlined />, '编辑连接', `调整 ${typeName} 连接的参数、认证方式与网络选项。`)
: renderConnectionModalTitle(<LinkOutlined />, `新建 ${typeName} 连接`, '填写连接参数、测试连通性,并保存到连接树中。');
};
const modalBodyStyle = {
padding: '12px 24px 18px',
height: CONNECTION_MODAL_BODY_HEIGHT,
overflowY: 'auto' as const,
overflowX: 'hidden' as const,
};
return (
<>
<Modal
title={getTitle()}
open={open}
onCancel={onClose}
footer={getFooter()}
centered
wrapClassName="connection-modal-wrap"
width={CONNECTION_MODAL_WIDTH}
zIndex={10001}
destroyOnHidden
maskClosable={false}
styles={{
content: modalShellStyle,
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: modalBodyStyle,
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 }
}}
>
{step === 1 ? renderStep1() : renderStep2()}
</Modal>
<Modal
title={renderConnectionModalTitle(<FileTextOutlined />, '测试连接失败原因', '查看本次测试连接的完整错误上下文,便于快速定位配置问题。')}
open={testErrorLogOpen}
onCancel={() => setTestErrorLogOpen(false)}
centered
width={760}
zIndex={10002}
destroyOnHidden
styles={{
content: modalShellStyle,
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8 },
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 }
}}
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;