import React, { useState, useEffect, useRef } from 'react'; import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd'; import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons'; import { useStore } from '../store'; import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectSSHKeyFile } from '../../wailsjs/go/app/App'; import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types'; const { Meta } = Card; const { Text } = Typography; const MAX_URI_LENGTH = 4096; const MAX_URI_HOSTS = 32; const MAX_TIMEOUT_SECONDS = 3600; const getDefaultPortByType = (type: string) => { switch (type) { case 'mysql': return 3306; case 'diros': return 9030; case 'sphinx': return 9306; case 'clickhouse': return 9000; case 'postgres': return 5432; case 'redis': return 6379; case 'tdengine': return 6041; case 'oracle': return 1521; case 'dameng': return 5236; case 'kingbase': return 54321; case 'sqlserver': return 1433; case 'mongodb': return 27017; case 'highgo': return 5866; case 'mariadb': return 3306; case 'vastbase': return 5432; case 'sqlite': return 0; case 'duckdb': return 0; default: return 3306; } }; const isFileDatabaseType = (type: string) => type === 'sqlite' || type === 'duckdb'; type DriverStatusSnapshot = { type: string; name: string; connectable: boolean; message?: string; }; const normalizeDriverType = (value: string): string => { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'postgresql') return 'postgres'; if (normalized === 'doris') return 'diros'; return normalized; }; const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialValues?: SavedConnection | null; onOpenDriverManager?: () => void; }> = ({ open, onClose, initialValues, onOpenDriverManager }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [useSSH, setUseSSH] = useState(false); const [useProxy, setUseProxy] = useState(false); const [dbType, setDbType] = useState('mysql'); const [step, setStep] = useState(1); // 1: Select Type, 2: Configure const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1 const [testResult, setTestResult] = useState<{ type: 'success' | 'error', message: string } | null>(null); const [testErrorLogOpen, setTestErrorLogOpen] = useState(false); const [dbList, setDbList] = useState([]); const [redisDbList, setRedisDbList] = useState([]); // Redis databases 0-15 const [mongoMembers, setMongoMembers] = useState([]); 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>({}); const [driverStatusLoaded, setDriverStatusLoaded] = useState(false); const [selectingSSHKey, setSelectingSSHKey] = useState(false); const testInFlightRef = useRef(false); const testTimerRef = useRef(null); const addConnection = useStore((state) => state.addConnection); const updateConnection = useStore((state) => state.updateConnection); const mysqlTopology = Form.useWatch('mysqlTopology', form) || 'single'; const mongoTopology = Form.useWatch('mongoTopology', form) || 'single'; const mongoSrv = Form.useWatch('mongoSrv', form) || false; const fetchDriverStatusMap = async (): Promise> => { const result: Record = {}; 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 => { 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(); 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(); const result: string[] = []; list.forEach((entry) => { const parsed = parseHostPort(String(entry || ''), defaultPort); if (!parsed?.host) { return; } const host = String(parsed.host).trim(); if (!host || seen.has(host)) { return; } seen.add(host); result.push(host); }); return result; }; const safeDecode = (text: string) => { try { return decodeURIComponent(text); } catch { return text; } }; const normalizeFileDbPath = (rawPath: string): string => { let pathText = String(rawPath || '').trim(); if (!pathText) { return ''; } // 兼容 sqlite:///C:/... 或 sqlite:///C:\... 解析后多出的前导斜杠。 if (/^\/[a-zA-Z]:[\\/]/.test(pathText)) { pathText = pathText.slice(1); } // 兼容历史版本把 Windows 文件路径误拼成 :3306:3306。 const legacyMatch = pathText.match(/^([a-zA-Z]:[\\/].*?)(?::\d+)+$/); if (legacyMatch?.[1]) { return legacyMatch[1]; } return pathText; }; const parseMultiHostUri = (uriText: string, expectedScheme: string) => { const prefix = `${expectedScheme}://`; if (!uriText.toLowerCase().startsWith(prefix)) { return null; } let rest = uriText.slice(prefix.length); const hashIndex = rest.indexOf('#'); if (hashIndex >= 0) { rest = rest.slice(0, hashIndex); } let queryText = ''; const queryIndex = rest.indexOf('?'); if (queryIndex >= 0) { queryText = rest.slice(queryIndex + 1); rest = rest.slice(0, queryIndex); } let pathText = ''; const slashIndex = rest.indexOf('/'); if (slashIndex >= 0) { pathText = rest.slice(slashIndex + 1); rest = rest.slice(0, slashIndex); } let hostText = rest; let username = ''; let password = ''; const atIndex = rest.lastIndexOf('@'); if (atIndex >= 0) { const userInfo = rest.slice(0, atIndex); hostText = rest.slice(atIndex + 1); const colonIndex = userInfo.indexOf(':'); if (colonIndex >= 0) { username = safeDecode(userInfo.slice(0, colonIndex)); password = safeDecode(userInfo.slice(colonIndex + 1)); } else { username = safeDecode(userInfo); } } const hosts = hostText .split(',') .map((item) => item.trim()) .filter(Boolean); return { username, password, hosts, database: safeDecode(pathText), params: new URLSearchParams(queryText), }; }; const parseUriToValues = (uriText: string, type: string): Record | null => { const trimmedUri = String(uriText || '').trim(); if (!trimmedUri) { return null; } if (trimmedUri.length > MAX_URI_LENGTH) { return null; } if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') { const mysqlDefaultPort = getDefaultPortByType(type); const parsed = parseMultiHostUri(trimmedUri, 'mysql') || parseMultiHostUri(trimmedUri, 'diros') || parseMultiHostUri(trimmedUri, 'doris'); if (!parsed) { return null; } if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { return null; } if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { return null; } const hostList = normalizeAddressList(parsed.hosts, mysqlDefaultPort); if (!hostList.length) { return null; } const primary = parseHostPort(hostList[0] || `localhost:${mysqlDefaultPort}`, mysqlDefaultPort); const timeoutValue = Number(parsed.params.get('timeout')); const topology = String(parsed.params.get('topology') || '').toLowerCase(); return { host: primary?.host || 'localhost', port: primary?.port || mysqlDefaultPort, user: parsed.username, password: parsed.password, database: parsed.database || '', mysqlTopology: hostList.length > 1 || topology === 'replica' ? 'replica' : 'single', mysqlReplicaHosts: hostList.slice(1), timeout: Number.isFinite(timeoutValue) && timeoutValue > 0 ? Math.min(3600, Math.trunc(timeoutValue)) : undefined, }; } if (isFileDatabaseType(type)) { const rawPath = trimmedUri .replace(/^sqlite:\/\//i, '') .replace(/^duckdb:\/\//i, '') .trim(); if (!rawPath) { return null; } return { host: normalizeFileDbPath(safeDecode(rawPath)) }; } if (type === 'mongodb') { const parsed = parseMultiHostUri(trimmedUri, 'mongodb') || parseMultiHostUri(trimmedUri, 'mongodb+srv'); if (!parsed) { return null; } if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { return null; } if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { return null; } const isSrv = trimmedUri.toLowerCase().startsWith('mongodb+srv://'); const hostList = isSrv ? normalizeMongoSrvHostList(parsed.hosts, 27017) : normalizeAddressList(parsed.hosts, 27017); if (!hostList.length) { return null; } const primary = isSrv ? { host: hostList[0] || 'localhost', port: 27017 } : parseHostPort(hostList[0] || 'localhost:27017', 27017); const timeoutMs = Number(parsed.params.get('connectTimeoutMS') || parsed.params.get('serverSelectionTimeoutMS')); return { host: primary?.host || 'localhost', port: primary?.port || 27017, user: parsed.username, password: parsed.password, database: parsed.database || '', mongoTopology: hostList.length > 1 || !!parsed.params.get('replicaSet') ? 'replica' : 'single', mongoHosts: hostList.slice(1), mongoSrv: isSrv, mongoReplicaSet: parsed.params.get('replicaSet') || '', mongoAuthSource: parsed.params.get('authSource') || '', mongoReadPreference: parsed.params.get('readPreference') || 'primary', mongoAuthMechanism: parsed.params.get('authMechanism') || '', timeout: Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.min(MAX_TIMEOUT_SECONDS, Math.ceil(timeoutMs / 1000)) : undefined, savePassword: true, }; } if (type === 'clickhouse') { const parsed = parseMultiHostUri(trimmedUri, 'clickhouse'); if (!parsed) { return null; } if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { return null; } if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { return null; } const hostList = normalizeAddressList(parsed.hosts, 9000); if (!hostList.length) { return null; } const primary = parseHostPort(hostList[0] || 'localhost:9000', 9000); return { host: primary?.host || 'localhost', port: primary?.port || 9000, user: parsed.username, password: parsed.password, database: parsed.database || '', }; } return null; }; const createUriAwareRequiredRule = ( messageText: string, validateValue?: (value: unknown) => boolean ) => ({ getFieldValue }: { getFieldValue: (name: string) => unknown }) => ({ validator(_: unknown, value: unknown) { const uriText = String(getFieldValue('uri') || '').trim(); const type = String(getFieldValue('type') || dbType).trim().toLowerCase(); if (uriText && parseUriToValues(uriText, type)) { return Promise.resolve(); } const valid = validateValue ? validateValue(value) : String(value ?? '').trim() !== ''; return valid ? Promise.resolve() : Promise.reject(new Error(messageText)); } }); const getUriPlaceholder = () => { if (dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') { const defaultPort = getDefaultPortByType(dbType); const scheme = dbType === 'diros' ? 'diros' : 'mysql'; return `${scheme}://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`; } if (isFileDatabaseType(dbType)) { return dbType === 'duckdb' ? 'duckdb:///Users/name/demo.duckdb' : 'sqlite:///Users/name/demo.sqlite'; } if (dbType === 'mongodb') { return 'mongodb+srv://user:pass@cluster0.example.com/db_name?authSource=admin&authMechanism=SCRAM-SHA-256'; } if (dbType === 'clickhouse') { return 'clickhouse://default:pass@127.0.0.1:9000/default'; } return '例如: postgres://user:pass@127.0.0.1:5432/db_name'; }; const buildUriFromValues = (values: any) => { const type = String(values.type || '').trim().toLowerCase(); const defaultPort = getDefaultPortByType(type); const host = String(values.host || 'localhost').trim(); const port = Number(values.port || defaultPort); const user = String(values.user || '').trim(); const password = String(values.password || ''); const database = String(values.database || '').trim(); const timeout = Number(values.timeout || 30); const encodedAuth = user ? `${encodeURIComponent(user)}${password ? `:${encodeURIComponent(password)}` : ''}@` : ''; if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') { const primary = toAddress(host, port, defaultPort); const replicas = values.mysqlTopology === 'replica' ? normalizeAddressList(values.mysqlReplicaHosts, defaultPort) : []; const hosts = normalizeAddressList([primary, ...replicas], defaultPort); const params = new URLSearchParams(); if (hosts.length > 1 || values.mysqlTopology === 'replica') { params.set('topology', 'replica'); } if (Number.isFinite(timeout) && timeout > 0) { params.set('timeout', String(timeout)); } const dbPath = database ? `/${encodeURIComponent(database)}` : '/'; const query = params.toString(); const scheme = type === 'diros' ? 'diros' : 'mysql'; return `${scheme}://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`; } if (isFileDatabaseType(type)) { const pathText = normalizeFileDbPath(String(values.host || '').trim()); if (!pathText) { return `${type}://`; } return `${type}://${encodeURI(pathText)}`; } if (type === 'mongodb') { const useSrv = !!values.mongoSrv; const primaryAddress = useSrv ? (parseHostPort(host, 27017)?.host || host || 'localhost') : toAddress(host, port, 27017); const extraNodes = values.mongoTopology === 'replica' ? (useSrv ? normalizeMongoSrvHostList(values.mongoHosts, 27017) : normalizeAddressList(values.mongoHosts, 27017)) : []; const hosts = useSrv ? normalizeMongoSrvHostList([primaryAddress, ...extraNodes], 27017) : normalizeAddressList([primaryAddress, ...extraNodes], 27017); const scheme = useSrv ? 'mongodb+srv' : 'mongodb'; const params = new URLSearchParams(); const authSource = String(values.mongoAuthSource || database || 'admin').trim(); if (authSource) { params.set('authSource', authSource); } const replicaSet = String(values.mongoReplicaSet || '').trim(); if (replicaSet) { params.set('replicaSet', replicaSet); } const readPreference = String(values.mongoReadPreference || '').trim(); if (readPreference) { params.set('readPreference', readPreference); } const authMechanism = String(values.mongoAuthMechanism || '').trim(); if (authMechanism) { params.set('authMechanism', authMechanism); } if (Number.isFinite(timeout) && timeout > 0) { params.set('connectTimeoutMS', String(timeout * 1000)); params.set('serverSelectionTimeoutMS', String(timeout * 1000)); } const dbPath = database ? `/${encodeURIComponent(database)}` : '/'; const query = params.toString(); return `${scheme}://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`; } const scheme = type === 'postgres' ? 'postgresql' : type; const dbPath = database ? `/${encodeURIComponent(database)}` : ''; return `${scheme}://${encodedAuth}${toAddress(host, port, defaultPort)}${dbPath}`; }; const handleGenerateURI = () => { try { const values = form.getFieldsValue(true); const uri = buildUriFromValues(values); form.setFieldValue('uri', uri); setUriFeedback({ type: 'success', message: 'URI 已生成' }); } catch { setUriFeedback({ type: 'error', message: '生成 URI 失败' }); } }; const handleParseURI = () => { try { const uriText = String(form.getFieldValue('uri') || '').trim(); const type = String(form.getFieldValue('type') || dbType).trim().toLowerCase(); if (!uriText) { setUriFeedback({ type: 'warning', message: '请先输入 URI' }); return; } const parsedValues = parseUriToValues(uriText, type); if (!parsedValues) { setUriFeedback({ type: 'error', message: '当前 URI 与数据源类型不匹配,或 URI 格式不支持' }); return; } form.setFieldsValue({ ...parsedValues, uri: uriText }); if (testResult) { setTestResult(null); } setUriFeedback({ type: 'success', message: '已根据 URI 回填连接参数' }); } catch { setUriFeedback({ type: 'error', message: 'URI 解析失败,请检查格式后重试' }); } }; const handleCopyURI = async () => { let uriText = String(form.getFieldValue('uri') || '').trim(); if (!uriText) { const values = form.getFieldsValue(true); uriText = buildUriFromValues(values); form.setFieldValue('uri', uriText); } if (!uriText) { setUriFeedback({ type: 'warning', message: '没有可复制的 URI' }); return; } try { await navigator.clipboard.writeText(uriText); setUriFeedback({ type: 'success', message: 'URI 已复制' }); } catch { setUriFeedback({ type: 'error', message: '复制失败' }); } }; const handleSelectSSHKeyFile = async () => { if (selectingSSHKey) { return; } try { setSelectingSSHKey(true); const currentPath = String(form.getFieldValue('sshKeyPath') || '').trim(); const res = await SelectSSHKeyFile(currentPath); if (res?.success) { const data = res.data || {}; const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim(); if (selectedPath) { form.setFieldValue('sshKeyPath', selectedPath); } } else if (res?.message !== 'Cancelled') { message.error(`选择私钥文件失败: ${res?.message || '未知错误'}`); } } catch (e: any) { message.error(`选择私钥文件失败: ${e?.message || String(e)}`); } finally { setSelectingSSHKey(false); } }; useEffect(() => { if (open) { setTestResult(null); // Reset test result setTestErrorLogOpen(false); setDbList([]); setRedisDbList([]); setMongoMembers([]); setUriFeedback(null); setTypeSelectWarning(null); setDriverStatusLoaded(false); void refreshDriverStatus(); if (initialValues) { // Edit mode: Go directly to step 2 setStep(2); const config: any = initialValues.config || {}; const configType = String(config.type || 'mysql'); const defaultPort = getDefaultPortByType(configType); const isFileDbConfigType = isFileDatabaseType(configType); const normalizedHosts = isFileDbConfigType ? [] : normalizeAddressList(config.hosts, defaultPort); const primaryAddress = isFileDbConfigType ? null : parseHostPort( normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort), defaultPort ); const primaryHost = isFileDbConfigType ? normalizeFileDbPath(String(config.host || '')) : (primaryAddress?.host || String(config.host || 'localhost')); const primaryPort = isFileDbConfigType ? 0 : (primaryAddress?.port || Number(config.port || defaultPort)); const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'diros' || configType === 'sphinx') ? normalizedHosts.slice(1) : []; const mongoHosts = configType === 'mongodb' ? normalizedHosts.slice(1) : []; const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0; const mongoIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mongoHosts.length > 0 || !!config.replicaSet; form.setFieldsValue({ type: configType, name: initialValues.name, host: primaryHost, port: primaryPort, user: config.user, password: config.password, database: config.database, uri: config.uri || '', includeDatabases: initialValues.includeDatabases, includeRedisDatabases: initialValues.includeRedisDatabases, useSSH: config.useSSH, sshHost: config.ssh?.host, sshPort: config.ssh?.port, sshUser: config.ssh?.user, sshPassword: config.ssh?.password, sshKeyPath: config.ssh?.keyPath, useProxy: config.useProxy, proxyType: config.proxy?.type || 'socks5', proxyHost: config.proxy?.host, proxyPort: config.proxy?.port, proxyUser: config.proxy?.user, proxyPassword: config.proxy?.password, driver: config.driver, dsn: config.dsn, timeout: config.timeout || 30, mysqlTopology: mysqlIsReplica ? 'replica' : 'single', mysqlReplicaHosts: mysqlReplicaHosts, mysqlReplicaUser: config.mysqlReplicaUser || '', mysqlReplicaPassword: config.mysqlReplicaPassword || '', mongoTopology: mongoIsReplica ? 'replica' : 'single', mongoHosts: mongoHosts, mongoSrv: !!config.mongoSrv, mongoReplicaSet: config.replicaSet || '', mongoAuthSource: config.authSource || '', mongoReadPreference: config.readPreference || 'primary', mongoAuthMechanism: config.mongoAuthMechanism || '', savePassword: config.savePassword !== false, mongoReplicaUser: config.mongoReplicaUser || '', mongoReplicaPassword: config.mongoReplicaPassword || '' }); setUseSSH(config.useSSH || false); setUseProxy(config.useProxy || false); setDbType(configType); // 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表 if (configType === 'redis') { setRedisDbList(Array.from({ length: 16 }, (_, i) => i)); } } else { // Create mode: Start at step 1 setStep(1); form.resetFields(); setUseSSH(false); setUseProxy(false); setDbType('mysql'); setActiveGroup(0); } } }, [open, initialValues]); useEffect(() => { return () => { if (testTimerRef.current !== null) { window.clearTimeout(testTimerRef.current); testTimerRef.current = null; } }; }, []); const handleOk = async () => { try { const values = await form.validateFields(); const unavailableReason = await resolveDriverUnavailableReason(values.type); if (unavailableReason) { message.warning(unavailableReason); promptInstallDriver(values.type, unavailableReason); return; } setLoading(true); const config = await buildConfig(values, true); const displayHost = String((config as any).host || values.host || '').trim(); const isRedisType = values.type === 'redis'; const newConn = { id: initialValues ? initialValues.id : Date.now().toString(), name: values.name || (isFileDatabaseType(values.type) ? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB') : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)), config: config, includeDatabases: values.includeDatabases, includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined }; if (initialValues) { updateConnection(newConn); message.success('配置已更新(未连接)'); } else { addConnection(newConn); message.success('配置已保存(未连接)'); } setLoading(false); form.resetFields(); setUseSSH(false); setUseProxy(false); setDbType('mysql'); setStep(1); onClose(); } catch (e) { setLoading(false); } }; const requestTest = () => { if (loading) return; if (testTimerRef.current !== null) return; testTimerRef.current = window.setTimeout(() => { testTimerRef.current = null; handleTest(); }, 0); }; const buildTestFailureMessage = (reason: unknown, fallback: string) => { const text = String(reason ?? '').trim(); const normalized = text && text !== 'undefined' && text !== 'null' ? text : fallback; return `测试失败: ${normalized}`; }; const handleTest = async () => { if (testInFlightRef.current) return; testInFlightRef.current = true; try { const values = await form.validateFields(); const unavailableReason = await resolveDriverUnavailableReason(values.type); if (unavailableReason) { const failMessage = buildTestFailureMessage(unavailableReason, '驱动未安装启用'); setTestResult({ type: 'error', message: failMessage }); promptInstallDriver(values.type, unavailableReason); return; } setLoading(true); setTestResult(null); const config = await buildConfig(values, false); // Use different API for Redis const isRedisType = values.type === 'redis'; const res = isRedisType ? await RedisConnect(config as any) : await TestConnection(config as any); if (res.success) { setTestResult({ type: 'success', message: res.message }); if (isRedisType) { // Redis: generate database list 0-15 setRedisDbList(Array.from({ length: 16 }, (_, i) => i)); } else { // Other databases: fetch database list const dbRes = await DBGetDatabases(config as any); if (dbRes.success) { const dbs = (dbRes.data as any[]).map((row: any) => row.Database || row.database); setDbList(dbs); } } } else { const failMessage = buildTestFailureMessage( res?.message, '连接被拒绝或参数无效,请检查后重试' ); setTestResult({ type: 'error', message: failMessage }); } } catch (e: unknown) { if (e && typeof e === 'object' && 'errorFields' in e) { const failMessage = '测试失败: 请先完善必填项后再测试连接'; setTestResult({ type: 'error', message: failMessage }); return; } const reason = e instanceof Error ? e.message : (typeof e === 'string' ? e : '未知异常'); const failMessage = buildTestFailureMessage(reason, '未知异常'); setTestResult({ type: 'error', message: failMessage }); } finally { testInFlightRef.current = false; setLoading(false); } }; const handleDiscoverMongoMembers = async () => { if (discoveringMembers || dbType !== 'mongodb') { return; } try { const values = await form.validateFields(); setDiscoveringMembers(true); const config = await buildConfig(values, false); const result = await MongoDiscoverMembers(config as any); if (!result.success) { message.error(result.message || '成员发现失败'); return; } const data = (result.data as Record) || {}; 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 => { const mergedValues = { ...values }; const parsedUriValues = parseUriToValues(mergedValues.uri, mergedValues.type); const isEmptyField = (value: unknown) => ( value === undefined || value === null || value === '' || value === 0 || (Array.isArray(value) && value.length === 0) ); if (parsedUriValues) { Object.entries(parsedUriValues).forEach(([key, value]) => { if (isEmptyField((mergedValues as any)[key])) { (mergedValues as any)[key] = value; } }); } const type = String(mergedValues.type || '').toLowerCase(); const defaultPort = getDefaultPortByType(type); const isFileDbType = isFileDatabaseType(type); let primaryHost = 'localhost'; let primaryPort = defaultPort; if (isFileDbType) { // 文件型数据库(sqlite/duckdb)这里的 host 即数据库文件路径,不应参与 host:port 拼接与解析。 primaryHost = normalizeFileDbPath(String(mergedValues.host || '').trim()); primaryPort = 0; } else { const parsedPrimary = parseHostPort( toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort), defaultPort ); primaryHost = parsedPrimary?.host || 'localhost'; primaryPort = parsedPrimary?.port || defaultPort; } let hosts: string[] = []; let topology: 'single' | 'replica' | undefined; let replicaSet = ''; let authSource = ''; let readPreference = ''; let mysqlReplicaUser = ''; let mysqlReplicaPassword = ''; let mongoSrvEnabled = false; let mongoAuthMechanism = ''; let mongoReplicaUser = ''; let mongoReplicaPassword = ''; const savePassword = type === 'mongodb' ? mergedValues.savePassword !== false : true; if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') { const replicas = mergedValues.mysqlTopology === 'replica' ? normalizeAddressList(mergedValues.mysqlReplicaHosts, defaultPort) : []; const allHosts = normalizeAddressList([`${primaryHost}:${primaryPort}`, ...replicas], defaultPort); if (mergedValues.mysqlTopology === 'replica' || allHosts.length > 1) { hosts = allHosts; topology = 'replica'; mysqlReplicaUser = String(mergedValues.mysqlReplicaUser || '').trim(); mysqlReplicaPassword = String(mergedValues.mysqlReplicaPassword || ''); } else { topology = 'single'; } } if (type === 'mongodb') { mongoSrvEnabled = !!mergedValues.mongoSrv; const extraHosts = mergedValues.mongoTopology === 'replica' ? (mongoSrvEnabled ? normalizeMongoSrvHostList(mergedValues.mongoHosts, defaultPort) : normalizeAddressList(mergedValues.mongoHosts, defaultPort)) : []; const primarySeed = mongoSrvEnabled ? primaryHost : `${primaryHost}:${primaryPort}`; const allHosts = mongoSrvEnabled ? normalizeMongoSrvHostList([primarySeed, ...extraHosts], defaultPort) : normalizeAddressList([primarySeed, ...extraHosts], defaultPort); if (mergedValues.mongoTopology === 'replica' || allHosts.length > 1 || mergedValues.mongoReplicaSet) { hosts = allHosts; topology = 'replica'; mongoReplicaUser = String(mergedValues.mongoReplicaUser || '').trim(); mongoReplicaPassword = String(mergedValues.mongoReplicaPassword || ''); } else { topology = 'single'; } replicaSet = String(mergedValues.mongoReplicaSet || '').trim(); authSource = String(mergedValues.mongoAuthSource || mergedValues.database || 'admin').trim(); readPreference = String(mergedValues.mongoReadPreference || 'primary').trim(); mongoAuthMechanism = String(mergedValues.mongoAuthMechanism || '').trim().toUpperCase(); } const sshConfig = mergedValues.useSSH ? { host: mergedValues.sshHost, port: Number(mergedValues.sshPort), user: mergedValues.sshUser, password: mergedValues.sshPassword || "", keyPath: mergedValues.sshKeyPath || "" } : { host: "", port: 22, user: "", password: "", keyPath: "" }; const effectiveUseProxy = !isFileDbType && !!mergedValues.useProxy; const proxyTypeRaw = String(mergedValues.proxyType || 'socks5').toLowerCase(); const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5'; const proxyConfig: NonNullable = effectiveUseProxy ? { type: proxyType, host: String(mergedValues.proxyHost || '').trim(), port: Number(mergedValues.proxyPort || (proxyTypeRaw === 'http' ? 8080 : 1080)), user: String(mergedValues.proxyUser || '').trim(), password: mergedValues.proxyPassword || "", } : { type: 'socks5', host: '', port: 1080, user: '', password: '', }; const keepPassword = !forPersist || savePassword; return { type: mergedValues.type, host: primaryHost, port: Number(primaryPort || 0), user: mergedValues.user || "", password: keepPassword ? (mergedValues.password || "") : "", savePassword: savePassword, database: mergedValues.database || "", useSSH: !!mergedValues.useSSH, ssh: sshConfig, useProxy: effectiveUseProxy, proxy: proxyConfig, driver: mergedValues.driver, dsn: mergedValues.dsn, timeout: Number(mergedValues.timeout || 30), uri: String(mergedValues.uri || '').trim(), hosts: hosts, topology: topology, mysqlReplicaUser: mysqlReplicaUser, mysqlReplicaPassword: keepPassword ? mysqlReplicaPassword : "", replicaSet: replicaSet, authSource: authSource, readPreference: readPreference, mongoSrv: mongoSrvEnabled, mongoAuthMechanism: mongoAuthMechanism, mongoReplicaUser: mongoReplicaUser, mongoReplicaPassword: keepPassword ? mongoReplicaPassword : "", }; }; const handleTypeSelect = async (type: string) => { const unavailableReason = await resolveDriverUnavailableReason(type); if (unavailableReason) { const normalized = normalizeDriverType(type); const driverName = driverStatusMap[normalized]?.name || type; setTypeSelectWarning({ driverName, reason: unavailableReason }); return; } setTypeSelectWarning(null); setDbType(type); form.setFieldsValue({ type: type }); const defaultPort = getDefaultPortByType(type); if (isFileDatabaseType(type)) { setUseSSH(false); setUseProxy(false); form.setFieldsValue({ host: '', port: 0, user: '', password: '', database: '', useSSH: false, sshHost: '', sshPort: 22, sshUser: '', sshPassword: '', sshKeyPath: '', useProxy: false, proxyType: 'socks5', proxyHost: '', proxyPort: 1080, proxyUser: '', proxyPassword: '', mysqlTopology: 'single', mongoTopology: 'single', mongoSrv: false, mongoReadPreference: 'primary', mongoReplicaSet: '', mongoAuthSource: '', mongoAuthMechanism: '', savePassword: true, mysqlReplicaHosts: [], mongoHosts: [], mysqlReplicaUser: '', mysqlReplicaPassword: '', mongoReplicaUser: '', mongoReplicaPassword: '', }); } else if (type !== 'custom') { const defaultUser = type === 'clickhouse' ? 'default' : 'root'; form.setFieldsValue({ user: defaultUser, database: '', port: defaultPort, mysqlTopology: 'single', mongoTopology: 'single', mongoSrv: false, mongoReadPreference: 'primary', mongoReplicaSet: '', mongoAuthSource: '', mongoAuthMechanism: '', savePassword: true, mysqlReplicaHosts: [], mongoHosts: [], mysqlReplicaUser: '', mysqlReplicaPassword: '', mongoReplicaUser: '', mongoReplicaPassword: '', }); } setMongoMembers([]); setStep(2); }; const isFileDb = isFileDatabaseType(dbType); const isCustom = dbType === 'custom'; const isRedis = dbType === 'redis'; const currentDriverType = normalizeDriverType(dbType); const currentDriverSnapshot = driverStatusMap[currentDriverType]; const currentDriverUnavailableReason = currentDriverType !== 'custom' && currentDriverSnapshot && !currentDriverSnapshot.connectable ? (currentDriverSnapshot.message || `${currentDriverSnapshot.name || dbType} 驱动未安装启用`) : ''; const driverStatusChecking = currentDriverType !== 'custom' && !driverStatusLoaded && step === 2; const dbTypeGroups = [ { label: '关系型数据库', items: [ { key: 'mysql', name: 'MySQL', icon: }, { key: 'mariadb', name: 'MariaDB', icon: }, { key: 'diros', name: 'Diros', icon: }, { key: 'sphinx', name: 'Sphinx', icon: }, { key: 'clickhouse', name: 'ClickHouse', icon: }, { key: 'postgres', name: 'PostgreSQL', icon: }, { key: 'sqlserver', name: 'SQL Server', icon: }, { key: 'sqlite', name: 'SQLite', icon: }, { key: 'duckdb', name: 'DuckDB', icon: }, { key: 'oracle', name: 'Oracle', icon: }, ]}, { label: '国产数据库', items: [ { key: 'dameng', name: 'Dameng (达梦)', icon: }, { key: 'kingbase', name: 'Kingbase (人大金仓)', icon: }, { key: 'highgo', name: 'HighGo (瀚高)', icon: }, { key: 'vastbase', name: 'Vastbase (海量)', icon: }, ]}, { label: 'NoSQL', items: [ { key: 'mongodb', name: 'MongoDB', icon: }, { key: 'redis', name: 'Redis', icon: }, ]}, { label: '时序数据库', items: [ { key: 'tdengine', name: 'TDengine', icon: }, ]}, { label: '其他', items: [ { key: 'custom', name: 'Custom (自定义)', icon: }, ]}, ]; const dbTypes = dbTypeGroups.flatMap(g => g.items); const renderStep1 = () => (
{typeSelectWarning && ( {typeSelectWarning.reason} )} onClose={() => setTypeSelectWarning(null)} /> )}
{/* 左侧分类导航 */}
{dbTypeGroups.map((group, idx) => (
setActiveGroup(idx)} style={{ padding: '10px 12px', cursor: 'pointer', borderRadius: 6, marginBottom: 4, background: activeGroup === idx ? '#e6f4ff' : 'transparent', color: activeGroup === idx ? '#1677ff' : undefined, fontWeight: activeGroup === idx ? 500 : 400, transition: 'all 0.2s', fontSize: 13, }} > {group.label}
))}
{/* 右侧数据源卡片 */}
{dbTypeGroups[activeGroup]?.items.map(item => ( { 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%' } }} >
{item.icon}
{item.name}
))}
); const renderStep2 = () => (
{ if (testResult) { setTestResult(null); // Clear result on change setTestErrorLogOpen(false); } if (changed.uri !== undefined || changed.type !== undefined) { setUriFeedback(null); } if (changed.useSSH !== undefined) setUseSSH(changed.useSSH); if (changed.useProxy !== undefined) setUseProxy(changed.useProxy); if (changed.proxyType !== undefined) { const nextType = String(changed.proxyType || 'socks5').toLowerCase(); if (nextType === 'http') { const currentPort = Number(form.getFieldValue('proxyPort') || 0); if (!currentPort || currentPort === 1080) { form.setFieldValue('proxyPort', 8080); } } else { const currentPort = Number(form.getFieldValue('proxyPort') || 0); if (!currentPort || currentPort === 8080) { form.setFieldValue('proxyPort', 1080); } } } // Type change handled by step 1, but keep sync if select changes (hidden now) if (changed.type !== undefined) setDbType(changed.type); if ( changed.type !== undefined || changed.host !== undefined || changed.port !== undefined || changed.mongoHosts !== undefined || changed.mongoTopology !== undefined || changed.mongoSrv !== undefined ) { setMongoMembers([]); } }} > {/* Hidden Type Field to keep form value synced */} {uriFeedback && ( setUriFeedback(null)} style={{ marginBottom: 12 }} /> )} {currentDriverUnavailableReason && ( {currentDriverUnavailableReason} )} /> )} {isCustom ? ( <> ) : ( <>
{!isFileDb && ( Number(value) > 0)]} style={{ width: 100 }} > )}
{(dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase') && ( )} {(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && ( <>
)} )} {dbType === 'mongodb' && ( <> 使用 SRV 记录(mongodb+srv) )} {mongoSrv && ( )}
发现后可校验当前副本集状态 {mongoMembers.length > 0 && ( `${record.host}-${record.state}`} dataSource={mongoMembers} style={{ marginBottom: 12 }} columns={[ { title: '成员', dataIndex: 'host', width: '48%', render: (value: string, record: MongoMemberInfo) => ( {value} {record.isSelf ? 当前 : null} ), }, { title: '状态', dataIndex: 'state', width: '32%', render: (value: string) => { const state = String(value || '').toUpperCase(); let color: string = 'default'; if (state === 'PRIMARY') color = 'success'; else if (state === 'SECONDARY' || state === 'PASSIVE') color = 'blue'; else if (state === 'ARBITER') color = 'purple'; else if (state === 'DOWN' || state === 'REMOVED' || state === 'UNKNOWN') color = 'error'; return {state || 'UNKNOWN'}; }, }, { title: '健康', dataIndex: 'healthy', width: '20%', render: (value: boolean) => ( {value ? '正常' : '异常'} ), }, ]} /> )} )} {redisDbList.map(db => db{db})} )} {/* Non-Redis, non-SQLite: username and password */} {!isFileDb && !isRedis && (
{dbType === 'mongodb' && ( {dbList.map(db => {db})} )} {!isFileDb && ( <> 使用 SSH 隧道 (SSH Tunnel) {useSSH && (
)} 使用代理 (SOCKS5 / HTTP CONNECT) {useProxy && (
)} ) }]} /> )} )} ); const getFooter = () => { if (step === 1) { return [ ]; } const isTestSuccess = testResult?.type === 'success'; const hasTestError = !!testResult && !isTestSuccess; const operationBlocked = !!currentDriverUnavailableReason || driverStatusChecking; return (
{!initialValues && } {testResult ? ( {isTestSuccess ? : } {isTestSuccess ? '连接成功' : '连接失败'} ) : null} {hasTestError && ( )}
); }; const getTitle = () => { if (step === 1) return "选择数据源类型"; const typeName = dbTypes.find(t => t.key === dbType)?.name || dbType; return initialValues ? "编辑连接" : `新建 ${typeName} 连接`; }; const modalBodyStyle = step === 1 ? { padding: '16px 24px', overflow: 'hidden' as const } : { padding: '16px 24px', overflowY: 'auto' as const, overflowX: 'hidden' as const, }; return ( <> {step === 1 ? renderStep1() : renderStep2()} setTestErrorLogOpen(false)} centered width={760} zIndex={10002} destroyOnHidden footer={[ , ]} >
              {String(testResult?.message || '暂无失败日志')}
          
); }; export default ConnectionModal;