diff --git a/frontend/src/App.css b/frontend/src/App.css index e34d510..99657b2 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -67,6 +67,11 @@ body[data-theme='dark'] { text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); } +/* 连接配置弹窗:滚动仅在弹窗 body 内部,不使用外层 wrap 滚动条 */ +.connection-modal-wrap { + overflow: hidden !important; +} + /* Custom Title Bar Close Button Hover */ .titlebar-close-btn:hover { background-color: #ff4d4f !important; diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index afb2863..9a85458 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -1,13 +1,31 @@ import React, { useState, useEffect, useRef } from 'react'; -import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse } from 'antd'; +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 } from '@ant-design/icons'; import { useStore } from '../store'; -import { DBGetDatabases, TestConnection, RedisConnect } from '../../wailsjs/go/app/App'; -import { SavedConnection } from '../types'; +import { DBGetDatabases, MongoDiscoverMembers, TestConnection, RedisConnect } from '../../wailsjs/go/app/App'; +import { MongoMemberInfo, SavedConnection } from '../types'; const { Meta } = Card; const { Text } = Typography; +const getDefaultPortByType = (type: string) => { + switch (type) { + case 'mysql': return 3306; + 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; + default: return 3306; + } +}; + const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialValues?: SavedConnection | null }> = ({ open, onClose, initialValues }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); @@ -18,43 +36,423 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal const [testResult, setTestResult] = useState<{ type: 'success' | 'error', message: string } | null>(null); const [dbList, setDbList] = useState([]); const [redisDbList, setRedisDbList] = useState([]); // Redis databases 0-15 + const [mongoMembers, setMongoMembers] = useState([]); + const [discoveringMembers, setDiscoveringMembers] = useState(false); const testInFlightRef = useRef(false); const testTimerRef = useRef(null); const addConnection = useStore((state) => state.addConnection); const updateConnection = useStore((state) => state.updateConnection); + const mysqlTopology = Form.useWatch('mysqlTopology', form) || 'single'; + const mongoTopology = Form.useWatch('mongoTopology', form) || 'single'; + const mongoSrv = Form.useWatch('mongoSrv', form) || false; + + const 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 : 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 : defaultPort, + }; + } + + return { host: text, port: defaultPort }; + }; + + const toAddress = (host: string, port: number, defaultPort: number) => { + const safeHost = String(host || '').trim() || 'localhost'; + const safePort = Number.isFinite(Number(port)) && Number(port) > 0 ? Number(port) : defaultPort; + return `${safeHost}:${safePort}`; + }; + + const normalizeAddressList = (rawList: unknown, defaultPort: number): string[] => { + const list = Array.isArray(rawList) ? rawList : []; + const seen = new Set(); + const result: string[] = []; + list.forEach((entry) => { + const parsed = parseHostPort(String(entry || ''), defaultPort); + if (!parsed) { + return; + } + const normalized = toAddress(parsed.host, parsed.port, defaultPort); + if (seen.has(normalized)) { + return; + } + seen.add(normalized); + result.push(normalized); + }); + return result; + }; + + const normalizeMongoSrvHostList = (rawList: unknown, defaultPort: number): string[] => { + const list = Array.isArray(rawList) ? rawList : []; + const seen = new Set(); + const result: string[] = []; + list.forEach((entry) => { + const parsed = parseHostPort(String(entry || ''), defaultPort); + if (!parsed?.host) { + return; + } + const host = String(parsed.host).trim(); + if (!host || seen.has(host)) { + return; + } + seen.add(host); + result.push(host); + }); + return result; + }; + + const safeDecode = (text: string) => { + try { + return decodeURIComponent(text); + } catch { + return text; + } + }; + + const parseMultiHostUri = (uriText: string, expectedScheme: string) => { + const prefix = `${expectedScheme}://`; + if (!uriText.toLowerCase().startsWith(prefix)) { + return null; + } + let rest = uriText.slice(prefix.length); + let queryText = ''; + const queryIndex = rest.indexOf('?'); + if (queryIndex >= 0) { + queryText = rest.slice(queryIndex + 1); + rest = rest.slice(0, queryIndex); + } + + let pathText = ''; + const slashIndex = rest.indexOf('/'); + if (slashIndex >= 0) { + pathText = rest.slice(slashIndex + 1); + rest = rest.slice(0, slashIndex); + } + + let hostText = rest; + let username = ''; + let password = ''; + const atIndex = rest.lastIndexOf('@'); + if (atIndex >= 0) { + const userInfo = rest.slice(0, atIndex); + hostText = rest.slice(atIndex + 1); + const colonIndex = userInfo.indexOf(':'); + if (colonIndex >= 0) { + username = safeDecode(userInfo.slice(0, colonIndex)); + password = safeDecode(userInfo.slice(colonIndex + 1)); + } else { + username = safeDecode(userInfo); + } + } + + const hosts = hostText + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + + return { + username, + password, + hosts, + database: safeDecode(pathText), + params: new URLSearchParams(queryText), + }; + }; + + const parseUriToValues = (uriText: string, type: string): Record | null => { + const trimmedUri = String(uriText || '').trim(); + if (!trimmedUri) { + return null; + } + + if (type === 'mysql' || type === 'mariadb') { + const parsed = parseMultiHostUri(trimmedUri, 'mysql'); + if (!parsed) { + return null; + } + const hostList = normalizeAddressList(parsed.hosts, 3306); + const primary = parseHostPort(hostList[0] || 'localhost:3306', 3306); + const timeoutValue = Number(parsed.params.get('timeout')); + return { + host: primary?.host || 'localhost', + port: primary?.port || 3306, + user: parsed.username, + password: parsed.password, + database: parsed.database || '', + mysqlTopology: hostList.length > 1 || parsed.params.get('topology') === 'replica' ? 'replica' : 'single', + mysqlReplicaHosts: hostList.slice(1), + timeout: Number.isFinite(timeoutValue) && timeoutValue > 0 ? timeoutValue : undefined, + }; + } + + if (type === 'mongodb') { + const parsed = parseMultiHostUri(trimmedUri, 'mongodb') || parseMultiHostUri(trimmedUri, 'mongodb+srv'); + if (!parsed) { + return null; + } + const isSrv = trimmedUri.toLowerCase().startsWith('mongodb+srv://'); + const hostList = isSrv + ? normalizeMongoSrvHostList(parsed.hosts, 27017) + : normalizeAddressList(parsed.hosts, 27017); + 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.ceil(timeoutMs / 1000) : undefined, + savePassword: true, + }; + } + + 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') { + return 'mysql://user:pass@127.0.0.1:3306,127.0.0.2:3306/db_name?topology=replica'; + } + if (dbType === 'mongodb') { + return 'mongodb+srv://user:pass@cluster0.example.com/db_name?authSource=admin&authMechanism=SCRAM-SHA-256'; + } + 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') { + const primary = toAddress(host, port, 3306); + const replicas = values.mysqlTopology === 'replica' + ? normalizeAddressList(values.mysqlReplicaHosts, 3306) + : []; + const hosts = normalizeAddressList([primary, ...replicas], 3306); + 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(); + return `mysql://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`; + } + + 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); + message.success('URI 已生成'); + } catch { + message.error('生成 URI 失败'); + } + }; + + const handleParseURI = () => { + const uriText = String(form.getFieldValue('uri') || '').trim(); + const type = String(form.getFieldValue('type') || dbType).trim().toLowerCase(); + if (!uriText) { + message.warning('请先输入 URI'); + return; + } + const parsedValues = parseUriToValues(uriText, type); + if (!parsedValues) { + message.error('当前 URI 与数据源类型不匹配,或 URI 格式不支持'); + return; + } + form.setFieldsValue({ ...parsedValues, uri: uriText }); + if (testResult) { + setTestResult(null); + } + message.success('已根据 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) { + message.warning('没有可复制的 URI'); + return; + } + try { + await navigator.clipboard.writeText(uriText); + message.success('URI 已复制'); + } catch { + message.error('复制失败'); + } + }; useEffect(() => { if (open) { setTestResult(null); // Reset test result setDbList([]); setRedisDbList([]); + setMongoMembers([]); 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 normalizedHosts = normalizeAddressList(config.hosts, defaultPort); + const primaryAddress = parseHostPort( + normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort), + defaultPort + ); + const primaryHost = primaryAddress?.host || String(config.host || 'localhost'); + const primaryPort = primaryAddress?.port || Number(config.port || defaultPort); + const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb') ? 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: initialValues.config.type, + type: configType, name: initialValues.name, - host: initialValues.config.host, - port: initialValues.config.port, - user: initialValues.config.user, - password: initialValues.config.password, - database: initialValues.config.database, + host: primaryHost, + port: primaryPort, + user: config.user, + password: config.password, + database: config.database, + uri: config.uri || '', includeDatabases: initialValues.includeDatabases, includeRedisDatabases: initialValues.includeRedisDatabases, - useSSH: initialValues.config.useSSH, - sshHost: initialValues.config.ssh?.host, - sshPort: initialValues.config.ssh?.port, - sshUser: initialValues.config.ssh?.user, - sshPassword: initialValues.config.ssh?.password, - sshKeyPath: initialValues.config.ssh?.keyPath, - driver: (initialValues.config as any).driver, - dsn: (initialValues.config as any).dsn, - timeout: (initialValues.config as any).timeout || 30 + useSSH: config.useSSH, + sshHost: config.ssh?.host, + sshPort: config.ssh?.port, + sshUser: config.ssh?.user, + sshPassword: config.ssh?.password, + sshKeyPath: config.ssh?.keyPath, + 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(initialValues.config.useSSH || false); - setDbType(initialValues.config.type); + setUseSSH(config.useSSH || false); + setDbType(configType); // 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表 - if (initialValues.config.type === 'redis') { + if (configType === 'redis') { setRedisDbList(Array.from({ length: 16 }, (_, i) => i)); } } else { @@ -82,12 +480,13 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal const values = await form.validateFields(); setLoading(true); - const config = await buildConfig(values); + 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 || (values.type === 'sqlite' ? 'SQLite DB' : (values.type === 'redis' ? `Redis ${values.host}` : values.host)), + name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)), config: config, includeDatabases: values.includeDatabases, includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined @@ -128,7 +527,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal const values = await form.validateFields(); setLoading(true); setTestResult(null); - const config = await buildConfig(values); + const config = await buildConfig(values, false); // Use different API for Redis const isRedisType = values.type === 'redis'; @@ -160,27 +559,160 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal } }; - const buildConfig = async (values: any) => { - const sshConfig = values.useSSH ? { - host: values.sshHost, - port: Number(values.sshPort), - user: values.sshUser, - password: values.sshPassword || "", - keyPath: values.sshKeyPath || "" + const handleDiscoverMongoMembers = async () => { + if (discoveringMembers || dbType !== 'mongodb') { + return; + } + try { + const values = await form.validateFields(); + setDiscoveringMembers(true); + const config = await buildConfig(values, false); + const result = await MongoDiscoverMembers(config as any); + if (!result.success) { + message.error(result.message || '成员发现失败'); + return; + } + const data = (result.data as Record) || {}; + const membersRaw = Array.isArray(data.members) ? data.members : []; + const members: MongoMemberInfo[] = membersRaw + .map((item: any) => ({ + host: String(item.host || '').trim(), + role: String(item.role || item.state || 'UNKNOWN').trim(), + state: String(item.state || item.role || 'UNKNOWN').trim(), + stateCode: Number(item.stateCode || 0), + healthy: !!item.healthy, + isSelf: !!item.isSelf, + })) + .filter((item: MongoMemberInfo) => !!item.host); + setMongoMembers(members); + if (!form.getFieldValue('mongoReplicaSet') && data.replicaSet) { + form.setFieldValue('mongoReplicaSet', String(data.replicaSet)); + } + message.success(result.message || `发现 ${members.length} 个成员`); + } catch (error: any) { + message.error(error?.message || '成员发现失败'); + } finally { + setDiscoveringMembers(false); + } + }; + + const buildConfig = async (values: any, forPersist: boolean) => { + 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 parsedPrimary = parseHostPort( + toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort), + defaultPort + ); + const primaryHost = parsedPrimary?.host || 'localhost'; + const 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') { + 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 keepPassword = !forPersist || savePassword; + return { - type: values.type, - host: values.host || "", - port: Number(values.port || 0), - user: values.user || "", - password: values.password || "", - database: values.database || "", - useSSH: !!values.useSSH, + 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, - driver: values.driver, - dsn: values.dsn, - timeout: Number(values.timeout || 30) + 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 : "", }; }; @@ -188,27 +720,28 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal setDbType(type); form.setFieldsValue({ type: type }); - // Auto-fill default port - let defaultPort = 3306; - switch (type) { - case 'mysql': defaultPort = 3306; break; - case 'postgres': defaultPort = 5432; break; - case 'redis': defaultPort = 6379; break; - case 'tdengine': defaultPort = 6041; break; - case 'oracle': defaultPort = 1521; break; - case 'dameng': defaultPort = 5236; break; - case 'kingbase': defaultPort = 54321; break; - case 'sqlserver': defaultPort = 1433; break; - case 'mongodb': defaultPort = 27017; break; - case 'highgo': defaultPort = 5866; break; - case 'mariadb': defaultPort = 3306; break; - case 'vastbase': defaultPort = 5432; break; - default: defaultPort = 3306; - } + const defaultPort = getDefaultPortByType(type); if (type !== 'sqlite' && type !== 'custom') { - form.setFieldsValue({ port: defaultPort }); + form.setFieldsValue({ + port: defaultPort, + mysqlTopology: 'single', + mongoTopology: 'single', + mongoSrv: false, + mongoReadPreference: 'primary', + mongoReplicaSet: '', + mongoAuthSource: '', + mongoAuthMechanism: '', + savePassword: true, + mysqlReplicaHosts: [], + mongoHosts: [], + mysqlReplicaUser: '', + mysqlReplicaPassword: '', + mongoReplicaUser: '', + mongoReplicaPassword: '', + }); } + setMongoMembers([]); setStep(2); }; @@ -294,12 +827,43 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
{ if (testResult) setTestResult(null); // Clear result on change if (changed.useSSH !== undefined) setUseSSH(changed.useSSH); // 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 */} @@ -308,6 +872,18 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal + + + + + + + + {isCustom ? ( <> @@ -321,19 +897,190 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal ) : ( <>
- + {!isSqlite && ( - + Number(value) > 0)]} + style={{ width: 100 }} + > )}
+ {(dbType === 'mysql' || dbType === 'mariadb') && ( + <> + + + +
+ + + + + + +
+ + )} + + )} + + {dbType === 'mongodb' && ( + <> + + 使用 SRV 记录(mongodb+srv) + + + + + )} + {mongoSrv && ( + + )} + + + +
+ + + + + + +
+ + + 发现后可校验当前副本集状态 + + {mongoMembers.length > 0 && ( + `${record.host}-${record.state}`} + dataSource={mongoMembers} + style={{ marginBottom: 12 }} + columns={[ + { + title: '成员', + dataIndex: 'host', + width: '48%', + render: (value: string, record: MongoMemberInfo) => ( + + {value} + {record.isSelf ? 当前 : null} + + ), + }, + { + title: '状态', + dataIndex: 'state', + width: '32%', + render: (value: string) => { + const state = String(value || '').toUpperCase(); + let color: string = 'default'; + if (state === 'PRIMARY') color = 'success'; + else if (state === 'SECONDARY' || state === 'PASSIVE') color = 'blue'; + else if (state === 'ARBITER') color = 'purple'; + else if (state === 'DOWN' || state === 'REMOVED' || state === 'UNKNOWN') color = 'error'; + return {state || 'UNKNOWN'}; + }, + }, + { + title: '健康', + dataIndex: 'healthy', + width: '20%', + render: (value: boolean) => ( + {value ? '正常' : '异常'} + ), + }, + ]} + /> + )} + + )} + + + + + + + + )} + {/* Redis specific: password only, no username */} {isRedis && ( <> @@ -351,7 +1098,12 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal {/* Non-Redis, non-SQLite: username and password */} {!isSqlite && !isRedis && (
- + @@ -360,6 +1112,12 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
)} + {dbType === 'mongodb' && ( + + 保存密码 + + )} + {!isSqlite && !isRedis && (
{childNode} @@ -480,9 +430,17 @@ interface DataGridProps { // Filtering showFilter?: boolean; onToggleFilter?: () => void; - onApplyFilter?: (conditions: any[]) => void; + onApplyFilter?: (conditions: GridFilterCondition[]) => void; } +type GridFilterCondition = FilterCondition & { + id: number; + column: string; + op: string; + value: string; + value2?: string; +}; + const DataGrid: React.FC = ({ data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false, onReload, onSort, onPageChange, pagination, showFilter, onToggleFilter, onApplyFilter @@ -527,7 +485,7 @@ const DataGrid: React.FC = ({ const cellEditorApplyRef = useRef<((val: string) => void) | null>(null); const [rowEditorOpen, setRowEditorOpen] = useState(false); const [rowEditorRowKey, setRowEditorRowKey] = useState(''); - const rowEditorBaseRef = useRef>({}); + const rowEditorBaseRawRef = useRef>({}); const rowEditorDisplayRef = useRef>({}); const rowEditorNullColsRef = useRef>(new Set()); const [rowEditorForm] = Form.useForm(); @@ -552,33 +510,20 @@ const DataGrid: React.FC = ({ const containerRef = useRef(null); const pendingScrollToBottomRef = useRef(false); - // 拖拽填充状态 - 只保留必要的 React 状态 - const [dragFillActive, setDragFillActive] = useState(false); - const dragFillGhostRef = useRef(null); - const dragFillRafRef = useRef(null); - // 使用 ref 存储拖拽数据,避免状态更新导致重渲染 - const dragFillDataRef = useRef<{ - startRecord: Item | null; - dataIndex: string; - startRowIndex: number; - currentRowIndex: number; - startCellRect: DOMRect | null; - colIndex: number; - // 缓存 DOM 查询结果 - cachedRows: HTMLElement[]; - cachedRowKeys: string[]; - cachedStartEl: HTMLElement | null; - }>({ - startRecord: null, - dataIndex: '', - startRowIndex: -1, - currentRowIndex: -1, - startCellRect: null, - colIndex: -1, - cachedRows: [], - cachedRowKeys: [], - cachedStartEl: null, - }); + // 批量编辑模式状态 + const [cellEditMode, setCellEditMode] = useState(false); + const [selectedCells, setSelectedCells] = useState>(new Set()); + const [batchEditModalOpen, setBatchEditModalOpen] = useState(false); + const [batchEditValue, setBatchEditValue] = useState(''); + const [batchEditSetNull, setBatchEditSetNull] = useState(false); + + // 使用 ref 来优化拖拽性能,完全避免状态更新 + const cellSelectionRafRef = useRef(null); + const cellSelectionScrollRafRef = useRef(null); + const isDraggingRef = useRef(false); + const currentSelectionRef = useRef>(new Set()); + const selectionStartRef = useRef<{ rowKey: string; colName: string; rowIndex: number; colIndex: number } | null>(null); + const rowIndexMapRef = useRef>(new Map()); const scrollTableBodyToBottom = useCallback(() => { const root = containerRef.current; @@ -704,7 +649,7 @@ const DataGrid: React.FC = ({ const [deletedRowKeys, setDeletedRowKeys] = useState>(new Set()); // Filter State - const [filterConditions, setFilterConditions] = useState<{ id: number, column: string, op: string, value: string, value2?: string }[]>([]); + const [filterConditions, setFilterConditions] = useState([]); const [nextFilterId, setNextFilterId] = useState(1); const selectedRowKeysRef = useRef(selectedRowKeys); @@ -730,7 +675,7 @@ const DataGrid: React.FC = ({ setSelectedRowKeys([]); setRowEditorOpen(false); setRowEditorRowKey(''); - rowEditorBaseRef.current = {}; + rowEditorBaseRawRef.current = {}; rowEditorDisplayRef.current = {}; rowEditorNullColsRef.current = new Set(); rowEditorForm.resetFields(); @@ -740,6 +685,259 @@ const DataGrid: React.FC = ({ const rowKeyStr = useCallback((k: React.Key) => String(k), []); + const columnIndexMap = useMemo(() => { + const map = new Map(); + columnNames.forEach((name, idx) => map.set(name, idx)); + return map; + }, [columnNames]); + + // 直接操作 DOM 更新选中效果,避免 React 重渲染 + const updateCellSelection = useCallback((newSelection: Set) => { + const tableBody = containerRef.current?.querySelector('.ant-table-body'); + if (!tableBody) return; + + // 只同步可见单元格(兼容 virtual 渲染 + 极大选区) + const visibleCells = tableBody.querySelectorAll('td[data-row-key][data-col-name]'); + visibleCells.forEach((cell) => { + const el = cell as HTMLElement; + const rowKey = el.getAttribute('data-row-key'); + const colName = el.getAttribute('data-col-name'); + if (!rowKey || !colName) return; + const key = makeCellKey(rowKey, colName); + if (newSelection.has(key)) { + if (el.getAttribute('data-cell-selected') !== 'true') el.setAttribute('data-cell-selected', 'true'); + } else { + if (el.hasAttribute('data-cell-selected')) el.removeAttribute('data-cell-selected'); + } + }); + }, []); + + // 批量填充选中的单元格 + const handleBatchFillCells = useCallback(() => { + const cellsToFill = currentSelectionRef.current; + if (cellsToFill.size === 0) { + message.info('请先选择要填充的单元格'); + return; + } + + const fillValue = batchEditSetNull ? null : batchEditValue; + + const addedRowMap = new Map(); + addedRows.forEach((r) => { + const k = r?.[GONAVI_ROW_KEY]; + if (k === undefined) return; + addedRowMap.set(rowKeyStr(k), r); + }); + + const baseRowMap = new Map(); + displayDataRef.current.forEach((r) => { + const k = r?.[GONAVI_ROW_KEY]; + if (k === undefined) return; + baseRowMap.set(rowKeyStr(k), r); + }); + + const patchesByRow = new Map>(); + let updatedCount = 0; + + cellsToFill.forEach((cellKey) => { + const parts = splitCellKey(cellKey); + if (!parts) return; + const { rowKey, colName } = parts; + + const existing = modifiedRows[rowKey]; + const baseRow = baseRowMap.get(rowKey); + let currentVal: any = undefined; + + const addedRow = addedRowMap.get(rowKey); + if (addedRow) { + currentVal = addedRow?.[colName]; + } else if (existing && Object.prototype.hasOwnProperty.call(existing as any, GONAVI_ROW_KEY)) { + currentVal = (existing as any)?.[colName]; + } else if (existing && Object.prototype.hasOwnProperty.call(existing as any, colName)) { + currentVal = (existing as any)?.[colName]; + } else { + currentVal = baseRow?.[colName]; + } + + const isSame = isCellValueEqualForDiff(currentVal, fillValue); + if (isSame) return; + + const patch = patchesByRow.get(rowKey) || {}; + patch[colName] = fillValue; + patchesByRow.set(rowKey, patch); + updatedCount++; + }); + + if (updatedCount === 0) { + message.info('选中的单元格无需更新'); + return; + } + + // 仅做一次状态提交,避免大量 setState 循环 + setAddedRows(prev => prev.map(r => { + const k = r?.[GONAVI_ROW_KEY]; + if (k === undefined) return r; + const patch = patchesByRow.get(rowKeyStr(k)); + if (!patch) return r; + return { ...r, ...patch }; + })); + + setModifiedRows(prev => { + let next: Record | null = null; + + patchesByRow.forEach((patch, keyStr) => { + if (addedRowMap.has(keyStr)) return; + + const existing = prev[keyStr]; + const merged = existing ? { ...(existing as any), ...patch } : patch; + if (!next) next = { ...prev }; + next[keyStr] = merged; + }); + + return next || prev; + }); + + message.success(`已填充 ${updatedCount} 个单元格`); + setBatchEditModalOpen(false); + + // 清除选中状态 + setSelectedCells(new Set()); + currentSelectionRef.current = new Set(); + selectionStartRef.current = null; + isDraggingRef.current = false; + updateCellSelection(new Set()); + }, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection]); + + // 事件委托:在容器级别处理批量编辑模式的鼠标事件 + useEffect(() => { + if (!cellEditMode) return; + + const container = containerRef.current; + if (!container) return; + + const getCellInfo = (target: HTMLElement): { rowKey: string; colName: string } | null => { + const td = target.closest('td[data-row-key][data-col-name]') as HTMLElement; + if (!td) return null; + const rowKey = td.getAttribute('data-row-key'); + const colName = td.getAttribute('data-col-name'); + if (!rowKey || !colName) return null; + return { rowKey, colName }; + }; + + const onMouseDown = (e: MouseEvent) => { + const cellInfo = getCellInfo(e.target as HTMLElement); + if (!cellInfo) return; + + e.preventDefault(); + isDraggingRef.current = true; + const currentData = displayDataRef.current; + const nextRowIndexMap = new Map(); + currentData.forEach((r, idx) => { + const k = r?.[GONAVI_ROW_KEY]; + if (k === undefined) return; + nextRowIndexMap.set(String(k), idx); + }); + rowIndexMapRef.current = nextRowIndexMap; + + const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1; + const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1; + selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex }; + currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]); + updateCellSelection(currentSelectionRef.current); + }; + + const onMouseMove = (e: MouseEvent) => { + if (!isDraggingRef.current || !selectionStartRef.current) return; + + const cellInfo = getCellInfo(e.target as HTMLElement); + if (!cellInfo) return; + + // 使用 RAF 节流 + if (cellSelectionRafRef.current !== null) { + cancelAnimationFrame(cellSelectionRafRef.current); + } + + cellSelectionRafRef.current = requestAnimationFrame(() => { + cellSelectionRafRef.current = null; + const start = selectionStartRef.current; + if (!start) return; + + const currentData = displayDataRef.current; + const rowIndexMap = rowIndexMapRef.current; + const startRowIndex = start.rowIndex; + const endRowIndex = rowIndexMap.get(cellInfo.rowKey) ?? -1; + if (startRowIndex === -1 || endRowIndex === -1) return; + + const startColIndex = start.colIndex; + const endColIndex = columnIndexMap.get(cellInfo.colName) ?? -1; + if (startColIndex === -1 || endColIndex === -1) return; + + const minRowIndex = Math.min(startRowIndex, endRowIndex); + const maxRowIndex = Math.max(startRowIndex, endRowIndex); + const minColIndex = Math.min(startColIndex, endColIndex); + const maxColIndex = Math.max(startColIndex, endColIndex); + + const newSelectedCells = new Set(); + for (let i = minRowIndex; i <= maxRowIndex; i++) { + const row = currentData[i]; + const rKey = String(row?.[GONAVI_ROW_KEY]); + for (let j = minColIndex; j <= maxColIndex; j++) { + newSelectedCells.add(makeCellKey(rKey, columnNames[j])); + } + } + + currentSelectionRef.current = newSelectedCells; + updateCellSelection(newSelectedCells); + }); + }; + + const onMouseUp = () => { + if (!isDraggingRef.current) return; + isDraggingRef.current = false; + + if (cellSelectionRafRef.current !== null) { + cancelAnimationFrame(cellSelectionRafRef.current); + cellSelectionRafRef.current = null; + } + + if (currentSelectionRef.current.size > 0) { + setSelectedCells(new Set(currentSelectionRef.current)); + } + }; + + const onScroll = () => { + if (currentSelectionRef.current.size === 0) return; + if (cellSelectionScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionScrollRafRef.current); + } + cellSelectionScrollRafRef.current = requestAnimationFrame(() => { + cellSelectionScrollRafRef.current = null; + updateCellSelection(currentSelectionRef.current); + }); + }; + + container.addEventListener('mousedown', onMouseDown); + container.addEventListener('mousemove', onMouseMove); + container.addEventListener('scroll', onScroll, true); + document.addEventListener('mouseup', onMouseUp); + + return () => { + container.removeEventListener('mousedown', onMouseDown); + container.removeEventListener('mousemove', onMouseMove); + container.removeEventListener('scroll', onScroll, true); + document.removeEventListener('mouseup', onMouseUp); + if (cellSelectionRafRef.current !== null) { + cancelAnimationFrame(cellSelectionRafRef.current); + cellSelectionRafRef.current = null; + } + if (cellSelectionScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionScrollRafRef.current); + cellSelectionScrollRafRef.current = null; + } + isDraggingRef.current = false; + }; + }, [cellEditMode, columnNames, columnIndexMap, updateCellSelection]); + // 批量填充到选中行 const handleBatchFillToSelected = useCallback((sourceRecord: Item, dataIndex: string) => { const sourceValue = sourceRecord[dataIndex]; @@ -760,250 +958,44 @@ const DataGrid: React.FC = ({ } // 批量更新 - let updatedCount = 0; - targetKeys.forEach(key => { - const keyStr = rowKeyStr(key); - const isAdded = addedRows.some(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr); + const addedKeySet = new Set(); + addedRows.forEach((r) => { + const k = r?.[GONAVI_ROW_KEY]; + if (k === undefined) return; + addedKeySet.add(rowKeyStr(k)); + }); - if (isAdded) { - setAddedRows(prev => prev.map(r => { - if (rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) { - updatedCount++; - return { ...r, [dataIndex]: sourceValue }; - } - return r; - })); - } else { - setModifiedRows(prev => { - const existing = prev[keyStr] || {}; - // 获取原始行数据 - const originalRow = displayDataRef.current.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr); - updatedCount++; - return { - ...prev, - [keyStr]: { ...originalRow, ...existing, [dataIndex]: sourceValue } - }; - }); - } + const targetKeyStrList = targetKeys.map(rowKeyStr); + const targetKeyStrSet = new Set(targetKeyStrList); + const updatedCount = targetKeyStrSet.size; + + setAddedRows(prev => prev.map(r => { + const k = r?.[GONAVI_ROW_KEY]; + if (k === undefined) return r; + const keyStr = rowKeyStr(k); + if (!targetKeyStrSet.has(keyStr)) return r; + return { ...r, [dataIndex]: sourceValue }; + })); + + setModifiedRows(prev => { + let next: Record | null = null; + + targetKeyStrSet.forEach((keyStr) => { + if (addedKeySet.has(keyStr)) return; + const existing = prev[keyStr]; + const patch = { [dataIndex]: sourceValue }; + const merged = existing ? { ...(existing as any), ...patch } : patch; + if (!next) next = { ...prev }; + next[keyStr] = merged; + }); + + return next || prev; }); message.success(`已填充 ${updatedCount} 行`); setCellContextMenu(prev => ({ ...prev, visible: false })); }, [addedRows, rowKeyStr]); - // 拖拽填充开始 - const handleDragFillStart = useCallback((record: Item, dataIndex: string, cellElement: HTMLElement) => { - const currentData = displayDataRef.current; - const rowKey = record?.[GONAVI_ROW_KEY]; - const rowIndex = currentData.findIndex(r => r?.[GONAVI_ROW_KEY] === rowKey); - - if (rowIndex === -1) return; - - const cellRect = cellElement.getBoundingClientRect(); - - // 预先计算列索引 - let colIndex = -1; - const headerRow = containerRef.current?.querySelector('.ant-table-thead tr'); - if (headerRow) { - const headerCells = headerRow.querySelectorAll('th'); - headerCells.forEach((th, idx) => { - const titleSpan = th.querySelector('.ant-table-column-title'); - const titleText = titleSpan?.textContent?.trim() || th.textContent?.trim(); - if (titleText === dataIndex) { - colIndex = idx; - } - }); - } - - // 预先缓存所有行的 DOM 元素和 key - const tableBody = containerRef.current?.querySelector('.ant-table-body'); - const rows = tableBody ? Array.from(tableBody.querySelectorAll('tr[data-row-key]')) as HTMLElement[] : []; - const rowKeys = rows.map(r => r.getAttribute('data-row-key') || ''); - const startKey = String(rowKey); - const startEl = rows.find((_, i) => rowKeys[i] === startKey) || null; - - // 存储到 ref - dragFillDataRef.current = { - startRecord: record, - dataIndex, - startRowIndex: rowIndex, - currentRowIndex: rowIndex, - startCellRect: cellRect, - colIndex, - cachedRows: rows, - cachedRowKeys: rowKeys, - cachedStartEl: startEl, - }; - - setDragFillActive(true); - document.body.style.cursor = 'crosshair'; - document.body.style.userSelect = 'none'; - }, []); - - // 拖拽填充移动(极致优化:最小化 DOM 操作) - const handleDragFillMove = useCallback((e: MouseEvent) => { - const data = dragFillDataRef.current; - if (!data.startRecord) return; - - const ghost = dragFillGhostRef.current; - if (!ghost) return; - - const mouseY = e.clientY; - const rows = data.cachedRows; - const rowKeys = data.cachedRowKeys; - const startEl = data.cachedStartEl; - - if (!startEl || rows.length === 0) { - ghost.style.display = 'none'; - return; - } - - // 二分查找优化:找到鼠标所在的行 - let endEl: HTMLElement = startEl; - let endIdx = data.startRowIndex; - - // 使用简单遍历(行数通常不多,二分查找收益有限) - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - const rect = row.getBoundingClientRect(); - - // 只需要检查行的底部边界 - if (mouseY >= rect.top) { - const currentData = displayDataRef.current; - const rowKey = rowKeys[i]; - const dataIdx = currentData.findIndex(r => String(r?.[GONAVI_ROW_KEY]) === rowKey); - - if (dataIdx > data.startRowIndex) { - endEl = row; - endIdx = dataIdx; - } - } - } - - data.currentRowIndex = endIdx; - - // 直接读取位置并更新样式(单次 reflow) - const startRect = startEl.getBoundingClientRect(); - const endRect = endEl.getBoundingClientRect(); - - const cells = startEl.querySelectorAll('td'); - const targetCell = (data.colIndex >= 0 && cells[data.colIndex]) ? cells[data.colIndex] : null; - const cellLeft = targetCell ? targetCell.getBoundingClientRect().left : data.startCellRect!.left; - const cellWidth = targetCell ? targetCell.getBoundingClientRect().width : data.startCellRect!.width; - - // 批量设置样式(浏览器会合并为一次重绘) - ghost.style.cssText = ` - position: fixed; - display: block; - left: ${cellLeft}px; - top: ${startRect.top}px; - width: ${cellWidth}px; - height: ${endRect.bottom - startRect.top}px; - border: 2px solid #1890ff; - background: rgba(24, 144, 255, 0.1); - pointer-events: none; - z-index: 9998; - `; - }, []); - - // 拖拽填充结束 - const handleDragFillEnd = useCallback(() => { - // 清理 RAF - if (dragFillRafRef.current !== null) { - cancelAnimationFrame(dragFillRafRef.current); - dragFillRafRef.current = null; - } - - const data = dragFillDataRef.current; - - if (!data.startRecord) { - setDragFillActive(false); - return; - } - - const { startRecord, dataIndex, startRowIndex, currentRowIndex } = data; - const sourceValue = startRecord[dataIndex]; - const currentData = displayDataRef.current; - - // 计算需要填充的行 - if (currentRowIndex > startRowIndex) { - let updatedCount = 0; - for (let i = startRowIndex + 1; i <= currentRowIndex && i < currentData.length; i++) { - const targetRow = currentData[i]; - const targetKey = targetRow?.[GONAVI_ROW_KEY]; - if (targetKey === undefined) continue; - - const keyStr = rowKeyStr(targetKey); - const step = i - startRowIndex; - const fillValue = smartIncrement(sourceValue, step); - - const isAdded = addedRows.some(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr); - - if (isAdded) { - setAddedRows(prev => prev.map(r => { - if (rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) { - updatedCount++; - return { ...r, [dataIndex]: fillValue }; - } - return r; - })); - } else { - setModifiedRows(prev => { - const existing = prev[keyStr] || {}; - updatedCount++; - return { - ...prev, - [keyStr]: { ...targetRow, ...existing, [dataIndex]: fillValue } - }; - }); - } - } - - if (updatedCount > 0) { - message.success(`已填充 ${updatedCount} 行`); - } - } - - // 重置状态 - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - - if (dragFillGhostRef.current) { - dragFillGhostRef.current.style.display = 'none'; - } - - // 重置 ref - dragFillDataRef.current = { - startRecord: null, - dataIndex: '', - startRowIndex: -1, - currentRowIndex: -1, - startCellRect: null, - colIndex: -1, - cachedRows: [], - cachedRowKeys: [], - cachedStartEl: null, - }; - - setDragFillActive(false); - }, [addedRows, rowKeyStr]); - - // 全局鼠标事件监听(拖拽填充) - useEffect(() => { - if (!dragFillActive) return; - - const handleMouseMove = (e: MouseEvent) => handleDragFillMove(e); - const handleMouseUp = () => handleDragFillEnd(); - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [dragFillActive, handleDragFillMove, handleDragFillEnd]); - const displayData = useMemo(() => { return [...data, ...addedRows].filter(item => { const k = item?.[GONAVI_ROW_KEY]; @@ -1204,7 +1196,7 @@ const DataGrid: React.FC = ({ const closeRowEditor = useCallback(() => { setRowEditorOpen(false); setRowEditorRowKey(''); - rowEditorBaseRef.current = {}; + rowEditorBaseRawRef.current = {}; rowEditorDisplayRef.current = {}; rowEditorNullColsRef.current = new Set(); rowEditorForm.resetFields(); @@ -1235,23 +1227,25 @@ const DataGrid: React.FC = ({ addedRows.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) || displayRow; - const baseMap: Record = {}; + const baseRawMap: Record = {}; const displayMap: Record = {}; + const formMap: Record = {}; const nullCols = new Set(); columnNames.forEach((col) => { const baseVal = (baseRow as any)?.[col]; const displayVal = (displayRow as any)?.[col]; - baseMap[col] = toFormText(baseVal); + baseRawMap[col] = baseVal; displayMap[col] = toFormText(displayVal); + formMap[col] = displayVal === null || displayVal === undefined ? undefined : toFormText(displayVal); if (baseVal === null || baseVal === undefined) nullCols.add(col); }); - rowEditorBaseRef.current = baseMap; + rowEditorBaseRawRef.current = baseRawMap; rowEditorDisplayRef.current = displayMap; rowEditorNullColsRef.current = nullCols; - rowEditorForm.setFieldsValue(displayMap); + rowEditorForm.setFieldsValue(formMap); setRowEditorRowKey(keyStr); setRowEditorOpen(true); }, [readOnly, tableName, selectedRowKeys, mergedDisplayData, data, addedRows, columnNames, rowEditorForm, rowKeyStr]); @@ -1279,13 +1273,12 @@ const DataGrid: React.FC = ({ return; } - const baseMap = rowEditorBaseRef.current || {}; + const baseRawMap = rowEditorBaseRawRef.current || {}; const patch: Record = {}; columnNames.forEach((col) => { const nextVal = values[col]; - const nextStr = toFormText(nextVal); - const baseStr = baseMap[col] ?? ''; - if (nextStr !== baseStr) patch[col] = nextStr; + const baseVal = baseRawMap[col]; + if (!isCellValueEqualForDiff(baseVal, nextVal)) patch[col] = nextVal; }); setModifiedRows(prev => { @@ -1390,9 +1383,7 @@ const DataGrid: React.FC = ({ columnNames.forEach((col) => { const nextVal = (newRow as any)?.[col]; const prevVal = (originalRow as any)?.[col]; - const nextStr = toFormText(nextVal); - const prevStr = toFormText(prevVal); - if (nextStr !== prevStr) values[col] = nextVal; + if (!isCellValueEqualForDiff(prevVal, nextVal)) values[col] = nextVal; }); } @@ -1697,18 +1688,19 @@ const DataGrid: React.FC = ({ const isListOp = useCallback((op: string) => op === 'IN' || op === 'NOT_IN', []); const addFilter = () => { - setFilterConditions([...filterConditions, { id: nextFilterId, column: columnNames[0] || '', op: '=', value: '', value2: '' }]); + setFilterConditions([...filterConditions, { id: nextFilterId, enabled: true, column: columnNames[0] || '', op: '=', value: '', value2: '' }]); setNextFilterId(nextFilterId + 1); }; - const updateFilter = (id: number, field: string, val: string) => { + const updateFilter = (id: number, field: keyof GridFilterCondition, val: string | boolean) => { setFilterConditions(prev => prev.map(c => { if (c.id !== id) return c; - const next: any = { ...c, [field]: val }; + const next: GridFilterCondition = { ...c, [field]: val } as GridFilterCondition; if (field === 'op') { - if (isNoValueOp(val)) { + const nextOp = String(val); + if (isNoValueOp(nextOp)) { next.value = ''; next.value2 = ''; - } else if (isBetweenOp(val)) { + } else if (isBetweenOp(nextOp)) { if (typeof next.value2 !== 'string') next.value2 = ''; } else { next.value2 = ''; @@ -1740,7 +1732,7 @@ const DataGrid: React.FC = ({ const enableVirtual = mergedDisplayData.length >= 200; return ( -
+
{/* Toolbar */}
{onReload && {selectedRowKeys.length > 0 && 已选 {selectedRowKeys.length}} +
+ + {cellEditMode && selectedCells.size > 0 && ( + <> + + + )}
{hasChanges && (