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