mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-07 05:02:44 +08:00
- 连接表单:验证方式新增"无认证 (None)"选项,MongoDB 用户名改为非必填 - URI 构建:当 MongoAuthMechanism 为 NONE 时跳过 user/password/authSource/authMechanism - 兼容优化:无用户名时不再默认设置 authSource=admin,避免驱动对无密码实例发起认证 - 双版本同步:mongodb_impl.go 与 mongodb_impl_v1.go 同步修改 - refs #303
2835 lines
141 KiB
TypeScript
2835 lines
141 KiB
TypeScript
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>使用 SRV(mongodb+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;
|