️ perf(data-grid): 重构批量编辑链路并优化表格渲染性能

- 重构批量改单元格的状态流,减少高频交互时的无效重渲染
- 优化大数据量场景下的表格交互流畅度与响应延迟
- 调整单元格编辑细节,增强与 Navicat 编辑习惯的一致性

🔧 fix(sidebar-connection): 修复多数据源切换后旧连接节点无响应问题

- 修复新建并连接新数据源后,旧数据源点击无响应的问题

 feat(tab-manager): 表与设计标签支持环境前缀显示

- 基于连接名识别 DEV/UAT/PROD/SIT/STG/TEST 环境标记
- 仅对 table/design 标签添加环境前缀,查询等标签保持原样
- 无法识别标准环境时回退显示连接名,提升多环境可辨识性

 feat(connection-config): 新增连接URI复制解析并支持MySQL/Mongo主从配置

- 连接弹窗新增 URI 生成、解析、复制能力,支持参数回填
- MySQL 支持多地址主从拓扑、从库地址列表与从库独立凭据
- Mongo 支持多节点配置、replicaSet、authSource、readPreference
- 扩展前后端连接配置模型并同步 Wails 生成类型文件
- 后端接入主从凭据回退策略,保持旧配置兼容

 feat(mongodb-replica): 对齐Navicat主从配置并补齐成员发现能力

- 新增 mongoSrv、mongoAuthMechanism、savePassword 配置项
- 支持 mongodb+srv URI 构建与解析,并透传 authMechanism
- 新增 MongoDiscoverMembers 接口,返回成员与状态信息
- 驱动侧实现 replSetGetStatus -> hello/isMaster 回退发现链路
- 前端弹窗新增 SRV 开关、验证方式、成员发现按钮与状态表
- 增加 SRV+SSH 冲突提示与后端保护,避免无效连接路径

🔧 fix(app-error-text): 修复连接测试错误信息乱码并完善日志提示

- 新增错误文本编码纠正能力,处理混合编码导致的中文乱码
- 连接错误提示统一走 normalizeErrorMessage 输出
- 增加 GB18030 纠正相关单元测试覆盖 PostgreSQL 认证失败场景
- go.mod 显式引入 golang.org/x/text 依赖

 feat(filter-panel): 筛选条件支持启用停用与批量开关

- 筛选条件新增 enabled 状态,支持按条件勾选启用/停用
- 筛选面板新增“全启用”“全停用”快捷操作
- SQL 组装时自动跳过已停用条件,保留条件内容便于复用
- 同步 DataViewer 与 SQL 工具层类型,确保筛选链路一致性

🔧 fix(connection-modal-scroll): 修复连接弹窗滚动行为并去除外层滚动条

- 连接配置步骤设置弹窗 body 最大高度与内部滚动
- 为连接弹窗增加专用 wrapClassName 并禁用外层滚动
- 修复出现双滚动条的问题,确保仅保留弹窗内部滚动条
This commit is contained in:
杨国锋
2026-02-09 21:54:11 +08:00
parent 35ed555857
commit 78e35a5be8
19 changed files with 2128 additions and 573 deletions

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges } from '
import { useStore } from '../store';
import { v4 as uuidv4 } from 'uuid';
import 'react-resizable/css/styles.css';
import { buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
import { buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, type FilterCondition } from '../utils/sql';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform } from '../utils/appearance';
// --- Error Boundary ---
@@ -59,6 +59,19 @@ class DataGridErrorBoundary extends React.Component<
// 内部行标识字段:避免与真实业务字段(如 `key` 列)冲突。
export const GONAVI_ROW_KEY = '__gonavi_row_key__';
// Cell key helpers for batch selection/fill.
// Use a control character separator to avoid collisions with rowKey/columnName contents (e.g. `new-123`).
const CELL_KEY_SEP = '\u0001';
const makeCellKey = (rowKey: string, colName: string) => `${rowKey}${CELL_KEY_SEP}${colName}`;
const splitCellKey = (cellKey: string): { rowKey: string; colName: string } | null => {
const sepIndex = cellKey.indexOf(CELL_KEY_SEP);
if (sepIndex === -1) return null;
return {
rowKey: cellKey.slice(0, sepIndex),
colName: cellKey.slice(sepIndex + CELL_KEY_SEP.length),
};
};
// Normalize RFC3339-like datetime strings to `YYYY-MM-DD HH:mm:ss` for display/editing.
// Also handle invalid datetime values like '0000-00-00 00:00:00'
const normalizeDateTimeString = (val: string) => {
@@ -109,48 +122,16 @@ const toFormText = (val: any): string => {
return toEditableText(val);
};
const INLINE_EDIT_MAX_CHARS = 2000;
/**
* 智能自增算法:
* - 纯数字:+1
* - 字符串末尾数字:末尾数字 +1保持前导零位数
* - 无数字:原值不变
*/
const smartIncrement = (value: any, step: number = 1): any => {
if (value === null || value === undefined) return value;
// 纯数字类型
if (typeof value === 'number') {
return value + step;
}
const str = String(value);
// 纯数字字符串
if (/^-?\d+(\.\d+)?$/.test(str)) {
const num = parseFloat(str);
if (Number.isInteger(num)) {
return String(num + step);
}
return String((num + step).toFixed((str.split('.')[1] || '').length));
}
// 字符串末尾数字模式(如 item_1, user001
const match = str.match(/^(.*?)(\d+)$/);
if (match) {
const prefix = match[1];
const numStr = match[2];
const num = parseInt(numStr, 10) + step;
// 保持前导零位数
const newNumStr = String(num).padStart(numStr.length, '0');
return prefix + newNumStr;
}
// 无法自增,返回原值
return value;
// 用于变更比较NULL 与 undefined 视为同类空值;与空字符串严格区分。
const isCellValueEqualForDiff = (left: any, right: any): boolean => {
const leftNullish = left === null || left === undefined;
const rightNullish = right === null || right === undefined;
if (leftNullish || rightNullish) return leftNullish && rightNullish;
return toFormText(left) === toFormText(right);
};
const INLINE_EDIT_MAX_CHARS = 2000;
const shouldOpenModalEditor = (val: any): boolean => {
if (val === null || val === undefined) return false;
if (typeof val === 'string') {
@@ -232,7 +213,6 @@ const EditableContext = React.createContext<any>(null);
const CellContextMenuContext = React.createContext<{
showMenu: (e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => void;
handleBatchFillToSelected: (record: Item, dataIndex: string) => void;
handleDragFillStart: (record: Item, dataIndex: string, cellElement: HTMLElement) => void;
} | null>(null);
const DataContext = React.createContext<{
selectedRowKeysRef: React.MutableRefObject<React.Key[]>;
@@ -271,9 +251,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
...restProps
}) => {
const [editing, setEditing] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const inputRef = useRef<any>(null);
const cellRef = useRef<HTMLTableCellElement>(null);
const form = useContext(EditableContext);
const cellContextMenuContext = useContext(CellContextMenuContext);
@@ -297,11 +275,9 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
const fieldName = getCellFieldName(record, dataIndex);
await form.validateFields([fieldName]);
const nextValue = form.getFieldValue(fieldName);
const prevText = toFormText(record?.[dataIndex]);
const nextText = toFormText(nextValue);
toggleEdit();
// 仅当值发生变化时才标记为修改,避免“双击-失焦”导致整行进入 modified 状态(蓝色高亮不清除)。
if (nextText !== prevText) {
if (!isCellValueEqualForDiff(record?.[dataIndex], nextValue)) {
handleSave({ ...record, [dataIndex]: nextValue });
}
// 保存后移除焦点
@@ -354,8 +330,6 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
className="editable-cell-value-wrap"
style={{ paddingRight: 24, minHeight: 20, position: 'relative' }}
onContextMenu={handleContextMenu}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{children}
</div>
@@ -377,7 +351,6 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
return (
<td
{...restProps}
ref={cellRef}
data-row-key={record ? String(record?.[GONAVI_ROW_KEY]) : undefined}
data-col-name={dataIndex || undefined}
onDoubleClick={editable ? handleDoubleClick : restProps?.onDoubleClick}
@@ -457,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<DataGridProps> = ({
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
onReload, onSort, onPageChange, pagination, showFilter, onToggleFilter, onApplyFilter
@@ -504,7 +485,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const cellEditorApplyRef = useRef<((val: string) => void) | null>(null);
const [rowEditorOpen, setRowEditorOpen] = useState(false);
const [rowEditorRowKey, setRowEditorRowKey] = useState<string>('');
const rowEditorBaseRef = useRef<Record<string, string>>({});
const rowEditorBaseRawRef = useRef<Record<string, any>>({});
const rowEditorDisplayRef = useRef<Record<string, string>>({});
const rowEditorNullColsRef = useRef<Set<string>>(new Set());
const [rowEditorForm] = Form.useForm();
@@ -532,44 +513,17 @@ const DataGrid: React.FC<DataGridProps> = ({
// 批量编辑模式状态
const [cellEditMode, setCellEditMode] = useState(false);
const [selectedCells, setSelectedCells] = useState<Set<string>>(new Set());
const [cellSelectionStart, setCellSelectionStart] = useState<{ rowKey: string; dataIndex: string } | null>(null);
const [batchEditModalOpen, setBatchEditModalOpen] = useState(false);
const [batchEditValue, setBatchEditValue] = useState('');
const [batchEditSetNull, setBatchEditSetNull] = useState(false);
// 使用 ref 来优化拖拽性能,完全避免状态更新
const cellSelectionRafRef = useRef<number | null>(null);
const cellSelectionScrollRafRef = useRef<number | null>(null);
const isDraggingRef = useRef(false);
const currentSelectionRef = useRef<Set<string>>(new Set());
const selectionStartRef = useRef<{ rowKey: string; dataIndex: string } | null>(null);
// 拖拽填充状态 - 只保留必要的 React 状态
const [dragFillActive, setDragFillActive] = useState(false);
const dragFillGhostRef = useRef<HTMLDivElement>(null);
const dragFillRafRef = useRef<number | null>(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 selectionStartRef = useRef<{ rowKey: string; colName: string; rowIndex: number; colIndex: number } | null>(null);
const rowIndexMapRef = useRef<Map<string, number>>(new Map());
const scrollTableBodyToBottom = useCallback(() => {
const root = containerRef.current;
@@ -695,7 +649,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const [deletedRowKeys, setDeletedRowKeys] = useState<Set<string>>(new Set());
// Filter State
const [filterConditions, setFilterConditions] = useState<{ id: number, column: string, op: string, value: string, value2?: string }[]>([]);
const [filterConditions, setFilterConditions] = useState<GridFilterCondition[]>([]);
const [nextFilterId, setNextFilterId] = useState(1);
const selectedRowKeysRef = useRef(selectedRowKeys);
@@ -721,7 +675,7 @@ const DataGrid: React.FC<DataGridProps> = ({
setSelectedRowKeys([]);
setRowEditorOpen(false);
setRowEditorRowKey('');
rowEditorBaseRef.current = {};
rowEditorBaseRawRef.current = {};
rowEditorDisplayRef.current = {};
rowEditorNullColsRef.current = new Set();
rowEditorForm.resetFields();
@@ -731,29 +685,29 @@ const DataGrid: React.FC<DataGridProps> = ({
const rowKeyStr = useCallback((k: React.Key) => String(k), []);
const columnIndexMap = useMemo(() => {
const map = new Map<string, number>();
columnNames.forEach((name, idx) => map.set(name, idx));
return map;
}, [columnNames]);
// 直接操作 DOM 更新选中效果,避免 React 重渲染
const updateCellSelection = useCallback((newSelection: Set<string>) => {
const tableBody = containerRef.current?.querySelector('.ant-table-body');
if (!tableBody) return;
// 移除所有旧的选中样式
const allCells = tableBody.querySelectorAll('td[data-cell-selected="true"]');
allCells.forEach(cell => {
(cell as HTMLElement).removeAttribute('data-cell-selected');
(cell as HTMLElement).style.background = '';
(cell as HTMLElement).style.outline = '';
(cell as HTMLElement).style.outlineOffset = '';
});
// 添加新的选中样式 - 使用 data-row-key 和 data-col-name 属性直接查找
newSelection.forEach(cellKey => {
const [rowKey, colName] = cellKey.split('-');
const cell = tableBody.querySelector(`td[data-row-key="${rowKey}"][data-col-name="${colName}"]`) as HTMLElement;
if (cell) {
cell.setAttribute('data-cell-selected', 'true');
cell.style.background = 'rgba(24, 144, 255, 0.1)';
cell.style.outline = '2px solid #1890ff';
cell.style.outlineOffset = '-2px';
// 只同步可见单元格(兼容 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');
}
});
}, []);
@@ -767,32 +721,80 @@ const DataGrid: React.FC<DataGridProps> = ({
}
const fillValue = batchEditSetNull ? null : batchEditValue;
const addedRowMap = new Map<string, any>();
addedRows.forEach((r) => {
const k = r?.[GONAVI_ROW_KEY];
if (k === undefined) return;
addedRowMap.set(rowKeyStr(k), r);
});
const baseRowMap = new Map<string, any>();
displayDataRef.current.forEach((r) => {
const k = r?.[GONAVI_ROW_KEY];
if (k === undefined) return;
baseRowMap.set(rowKeyStr(k), r);
});
const patchesByRow = new Map<string, Record<string, any>>();
let updatedCount = 0;
cellsToFill.forEach(cellKey => {
const [rowKey, dataIndex] = cellKey.split('-');
const keyStr = rowKey;
const isAdded = addedRows.some(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr);
cellsToFill.forEach((cellKey) => {
const parts = splitCellKey(cellKey);
if (!parts) return;
const { rowKey, colName } = parts;
if (isAdded) {
setAddedRows(prev => prev.map(r => {
if (rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) {
updatedCount++;
return { ...r, [dataIndex]: fillValue };
}
return r;
}));
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 {
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]: fillValue }
};
});
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<string, any> | 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} 个单元格`);
@@ -800,11 +802,11 @@ const DataGrid: React.FC<DataGridProps> = ({
// 清除选中状态
setSelectedCells(new Set());
setCellSelectionStart(null);
currentSelectionRef.current = new Set();
selectionStartRef.current = null;
isDraggingRef.current = false;
updateCellSelection(new Set());
}, [batchEditValue, batchEditSetNull, addedRows, rowKeyStr, updateCellSelection]);
}, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection]);
// 事件委托:在容器级别处理批量编辑模式的鼠标事件
useEffect(() => {
@@ -828,8 +830,19 @@ const DataGrid: React.FC<DataGridProps> = ({
e.preventDefault();
isDraggingRef.current = true;
selectionStartRef.current = { rowKey: cellInfo.rowKey, dataIndex: cellInfo.colName };
currentSelectionRef.current = new Set([`${cellInfo.rowKey}-${cellInfo.colName}`]);
const currentData = displayDataRef.current;
const nextRowIndexMap = new Map<string, number>();
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);
};
@@ -850,12 +863,13 @@ const DataGrid: React.FC<DataGridProps> = ({
if (!start) return;
const currentData = displayDataRef.current;
const startRowIndex = currentData.findIndex(r => String(r?.[GONAVI_ROW_KEY]) === start.rowKey);
const endRowIndex = currentData.findIndex(r => String(r?.[GONAVI_ROW_KEY]) === cellInfo.rowKey);
const rowIndexMap = rowIndexMapRef.current;
const startRowIndex = start.rowIndex;
const endRowIndex = rowIndexMap.get(cellInfo.rowKey) ?? -1;
if (startRowIndex === -1 || endRowIndex === -1) return;
const startColIndex = columnNames.indexOf(start.dataIndex);
const endColIndex = columnNames.indexOf(cellInfo.colName);
const startColIndex = start.colIndex;
const endColIndex = columnIndexMap.get(cellInfo.colName) ?? -1;
if (startColIndex === -1 || endColIndex === -1) return;
const minRowIndex = Math.min(startRowIndex, endRowIndex);
@@ -868,7 +882,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const row = currentData[i];
const rKey = String(row?.[GONAVI_ROW_KEY]);
for (let j = minColIndex; j <= maxColIndex; j++) {
newSelectedCells.add(`${rKey}-${columnNames[j]}`);
newSelectedCells.add(makeCellKey(rKey, columnNames[j]));
}
}
@@ -888,20 +902,41 @@ const DataGrid: React.FC<DataGridProps> = ({
if (currentSelectionRef.current.size > 0) {
setSelectedCells(new Set(currentSelectionRef.current));
setCellSelectionStart(selectionStartRef.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, updateCellSelection]);
}, [cellEditMode, columnNames, columnIndexMap, updateCellSelection]);
// 批量填充到选中行
const handleBatchFillToSelected = useCallback((sourceRecord: Item, dataIndex: string) => {
@@ -923,250 +958,44 @@ const DataGrid: React.FC<DataGridProps> = ({
}
// 批量更新
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<string>();
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<string, any> | 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];
@@ -1367,7 +1196,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const closeRowEditor = useCallback(() => {
setRowEditorOpen(false);
setRowEditorRowKey('');
rowEditorBaseRef.current = {};
rowEditorBaseRawRef.current = {};
rowEditorDisplayRef.current = {};
rowEditorNullColsRef.current = new Set();
rowEditorForm.resetFields();
@@ -1398,23 +1227,25 @@ const DataGrid: React.FC<DataGridProps> = ({
addedRows.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) ||
displayRow;
const baseMap: Record<string, string> = {};
const baseRawMap: Record<string, any> = {};
const displayMap: Record<string, string> = {};
const formMap: Record<string, any> = {};
const nullCols = new Set<string>();
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]);
@@ -1442,13 +1273,12 @@ const DataGrid: React.FC<DataGridProps> = ({
return;
}
const baseMap = rowEditorBaseRef.current || {};
const baseRawMap = rowEditorBaseRawRef.current || {};
const patch: Record<string, any> = {};
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 => {
@@ -1553,9 +1383,7 @@ const DataGrid: React.FC<DataGridProps> = ({
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;
});
}
@@ -1860,18 +1688,19 @@ const DataGrid: React.FC<DataGridProps> = ({
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 = '';
@@ -1931,21 +1760,30 @@ const DataGrid: React.FC<DataGridProps> = ({
{selectedRowKeys.length > 0 && <span style={{ fontSize: '12px', color: '#888' }}> {selectedRowKeys.length}</span>}
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
<Button
icon={<EditOutlined />}
type={cellEditMode ? 'primary' : 'default'}
onClick={() => {
setCellEditMode(!cellEditMode);
setSelectedCells(new Set());
setCellSelectionStart(null);
if (cellEditMode) {
message.info('已退出单元格编辑模式');
} else {
message.info('已进入单元格编辑模式,可拖拽选择多个单元格');
}
}}
>
</Button>
icon={<EditOutlined />}
type={cellEditMode ? 'primary' : 'default'}
onClick={() => {
const next = !cellEditMode;
setCellEditMode(next);
setSelectedCells(new Set());
currentSelectionRef.current = new Set();
selectionStartRef.current = null;
isDraggingRef.current = false;
if (cellSelectionRafRef.current !== null) {
cancelAnimationFrame(cellSelectionRafRef.current);
cellSelectionRafRef.current = null;
}
if (cellSelectionScrollRafRef.current !== null) {
cancelAnimationFrame(cellSelectionScrollRafRef.current);
cellSelectionScrollRafRef.current = null;
}
updateCellSelection(new Set());
if (!next) setBatchEditModalOpen(false);
message.info(next ? '已进入单元格编辑模式,可拖拽选择多个单元格' : '已退出单元格编辑模式');
}}
>
</Button>
{cellEditMode && selectedCells.size > 0 && (
<>
<Button
@@ -1990,7 +1828,14 @@ const DataGrid: React.FC<DataGridProps> = ({
background: bgFilter,
}}>
{filterConditions.map(cond => (
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'flex-start' }}>
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'flex-start', opacity: cond.enabled === false ? 0.58 : 1 }}>
<Checkbox
checked={cond.enabled !== false}
onChange={e => updateFilter(cond.id, 'enabled', e.target.checked)}
style={{ marginTop: 6 }}
>
</Checkbox>
<Select
style={{ width: 180 }}
value={cond.column}
@@ -2051,6 +1896,8 @@ const DataGrid: React.FC<DataGridProps> = ({
))}
<div style={{ display: 'flex', gap: 8 }}>
<Button type="dashed" onClick={addFilter} size="small" icon={<PlusOutlined />}></Button>
<Button size="small" onClick={() => setFilterConditions(prev => prev.map(c => ({ ...c, enabled: true })))}></Button>
<Button size="small" onClick={() => setFilterConditions(prev => prev.map(c => ({ ...c, enabled: false })))}></Button>
<Button type="primary" onClick={applyFilters} size="small"></Button>
<Button size="small" icon={<ClearOutlined />} onClick={() => {
setFilterConditions([]);
@@ -2174,7 +2021,7 @@ const DataGrid: React.FC<DataGridProps> = ({
<Form component={false} form={form}>
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu, handleBatchFillToSelected, handleDragFillStart }}>
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu, handleBatchFillToSelected }}>
<EditableContext.Provider value={form}>
<Table
components={tableComponents}
@@ -2410,6 +2257,10 @@ const DataGrid: React.FC<DataGridProps> = ({
.${gridId} .ant-table-tbody > tr.row-added:hover > td { background-color: ${rowAddedHover} !important; }
.${gridId} .ant-table-tbody > tr.row-modified:hover > td { background-color: ${rowModHover} !important; }
.${gridId}.cell-edit-mode .ant-table-tbody > tr > td[data-col-name] { user-select: none; -webkit-user-select: none; cursor: crosshair; }
.${gridId}.cell-edit-mode .ant-table-tbody > tr > td[data-cell-selected="true"] {
box-shadow: inset 0 0 0 2px #1890ff;
background-image: linear-gradient(${darkMode ? 'rgba(24, 144, 255, 0.18)' : 'rgba(24, 144, 255, 0.08)'}, ${darkMode ? 'rgba(24, 144, 255, 0.18)' : 'rgba(24, 144, 255, 0.08)'});
}
`}</style>
{/* Ghost Resize Line for Columns */}
@@ -2428,21 +2279,6 @@ const DataGrid: React.FC<DataGridProps> = ({
willChange: 'transform'
}}
/>
{/* 拖拽填充选区指示器 - 使用 fixed 定位基于视口 */}
{dragFillActive && createPortal(
<div
ref={dragFillGhostRef}
style={{
position: 'fixed',
border: '2px solid #1890ff',
background: 'rgba(24, 144, 255, 0.1)',
pointerEvents: 'none',
zIndex: 9998,
display: 'none',
}}
/>,
document.body
)}
</div>
);
};

View File

@@ -4,7 +4,7 @@ import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { buildWhereSQL, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
import { buildWhereSQL, quoteIdentPart, quoteQualifiedIdent, type FilterCondition } from '../utils/sql';
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const [data, setData] = useState<any[]>([]);
@@ -29,7 +29,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
const [showFilter, setShowFilter] = useState(false);
const [filterConditions, setFilterConditions] = useState<any[]>([]);
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>([]);
const currentConnType = (connections.find(c => c.id === tab.connectionId)?.config?.type || '').toLowerCase();
const forceReadOnly = currentConnType === 'tdengine';
@@ -220,7 +220,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const handleSort = useCallback((field: string, order: string) => setSortInfo({ columnKey: field, order }), []);
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
const handleApplyFilter = useCallback((conditions: any[]) => setFilterConditions(conditions), []);
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []);
useEffect(() => {
fetchData(1, pagination.pageSize);

View File

@@ -148,14 +148,25 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}, [savedQueries]);
useEffect(() => {
setTreeData(connections.map(conn => ({
title: conn.name,
key: conn.id,
icon: conn.config.type === 'redis' ? <CloudOutlined style={{ color: '#DC382D' }} /> : <HddOutlined />,
type: 'connection',
dataRef: conn,
isLeaf: false,
})));
setTreeData((prev) => {
const prevMap = new Map<string, TreeNode>();
prev.forEach((node) => {
prevMap.set(String(node.key), node);
});
return connections.map((conn) => {
const existing = prevMap.get(conn.id);
return {
title: conn.name,
key: conn.id,
icon: conn.config.type === 'redis' ? <CloudOutlined style={{ color: '#DC382D' }} /> : <HddOutlined />,
type: 'connection',
dataRef: conn,
isLeaf: false,
children: existing?.children,
} as TreeNode;
});
});
}, [connections]);
const updateTreeData = (list: TreeNode[], key: React.Key, children: TreeNode[] | undefined): TreeNode[] => {

View File

@@ -8,9 +8,29 @@ import TableDesigner from './TableDesigner';
import RedisViewer from './RedisViewer';
import RedisCommandEditor from './RedisCommandEditor';
import TriggerViewer from './TriggerViewer';
import type { TabData } from '../types';
const detectConnectionEnvLabel = (connectionName: string): string | null => {
const tokens = connectionName.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
if (tokens.includes('prod') || tokens.includes('production')) return 'PROD';
if (tokens.includes('uat')) return 'UAT';
if (tokens.includes('dev') || tokens.includes('development')) return 'DEV';
if (tokens.includes('sit')) return 'SIT';
if (tokens.includes('stg') || tokens.includes('stage') || tokens.includes('staging') || tokens.includes('pre')) return 'STG';
if (tokens.includes('test') || tokens.includes('qa')) return 'TEST';
return null;
};
const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): string => {
if (tab.type !== 'table' && tab.type !== 'design') return tab.title;
if (!connectionName) return tab.title;
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
return `[${prefix}] ${tab.title}`;
};
const TabManager: React.FC = () => {
const tabs = useStore(state => state.tabs);
const connections = useStore(state => state.connections);
const activeTabId = useStore(state => state.activeTabId);
const setActiveTab = useStore(state => state.setActiveTab);
const closeTab = useStore(state => state.closeTab);
@@ -30,6 +50,8 @@ const TabManager: React.FC = () => {
};
const items = useMemo(() => tabs.map((tab, index) => {
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
const displayTitle = buildTabDisplayTitle(tab, connectionName);
let content;
if (tab.type === 'query') {
content = <QueryEditor tab={tab} />;
@@ -76,13 +98,13 @@ const TabManager: React.FC = () => {
return {
label: (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<span onContextMenu={(e) => e.preventDefault()}>{tab.title}</span>
<span onContextMenu={(e) => e.preventDefault()}>{displayTitle}</span>
</Dropdown>
),
key: tab.id,
children: content,
};
}), [tabs, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
return (
<>

View File

@@ -12,10 +12,32 @@ export interface ConnectionConfig {
port: number;
user: string;
password?: string;
savePassword?: boolean;
database?: string;
useSSH?: boolean;
ssh?: SSHConfig;
redisDB?: number; // Redis database index (0-15)
uri?: string; // Connection URI for copy/paste
hosts?: string[]; // Multi-host addresses: host:port
topology?: 'single' | 'replica';
mysqlReplicaUser?: string;
mysqlReplicaPassword?: string;
replicaSet?: string;
authSource?: string;
readPreference?: string;
mongoSrv?: boolean;
mongoAuthMechanism?: string;
mongoReplicaUser?: string;
mongoReplicaPassword?: string;
}
export interface MongoMemberInfo {
host: string;
role: string;
state: string;
stateCode?: number;
healthy: boolean;
isSelf?: boolean;
}
export interface SavedConnection {

View File

@@ -1,5 +1,6 @@
export type FilterCondition = {
id?: number;
enabled?: boolean;
column?: string;
op?: string;
value?: string;
@@ -75,6 +76,8 @@ export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) =>
const whereParts: string[] = [];
(conditions || []).forEach((cond) => {
if (cond?.enabled === false) return;
const op = (cond?.op || '').trim();
const column = (cond?.column || '').trim();
const value = (cond?.value ?? '').toString();

View File

@@ -62,6 +62,8 @@ export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:str
export function InstallUpdateAndRestart():Promise<connection.QueryResult>;
export function MongoDiscoverMembers(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function MySQLConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function MySQLGetDatabases(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;

View File

@@ -118,6 +118,10 @@ export function InstallUpdateAndRestart() {
return window['go']['app']['App']['InstallUpdateAndRestart']();
}
export function MongoDiscoverMembers(arg1) {
return window['go']['app']['App']['MongoDiscoverMembers'](arg1);
}
export function MySQLConnect(arg1) {
return window['go']['app']['App']['MySQLConnect'](arg1);
}

View File

@@ -74,6 +74,7 @@ export namespace connection {
port: number;
user: string;
password: string;
savePassword?: boolean;
database: string;
useSSH: boolean;
ssh: SSHConfig;
@@ -81,6 +82,18 @@ export namespace connection {
dsn?: string;
timeout?: number;
redisDB?: number;
uri?: string;
hosts?: string[];
topology?: string;
mysqlReplicaUser?: string;
mysqlReplicaPassword?: string;
replicaSet?: string;
authSource?: string;
readPreference?: string;
mongoSrv?: boolean;
mongoAuthMechanism?: string;
mongoReplicaUser?: string;
mongoReplicaPassword?: string;
static createFrom(source: any = {}) {
return new ConnectionConfig(source);
@@ -93,6 +106,7 @@ export namespace connection {
this.port = source["port"];
this.user = source["user"];
this.password = source["password"];
this.savePassword = source["savePassword"];
this.database = source["database"];
this.useSSH = source["useSSH"];
this.ssh = this.convertValues(source["ssh"], SSHConfig);
@@ -100,6 +114,18 @@ export namespace connection {
this.dsn = source["dsn"];
this.timeout = source["timeout"];
this.redisDB = source["redisDB"];
this.uri = source["uri"];
this.hosts = source["hosts"];
this.topology = source["topology"];
this.mysqlReplicaUser = source["mysqlReplicaUser"];
this.mysqlReplicaPassword = source["mysqlReplicaPassword"];
this.replicaSet = source["replicaSet"];
this.authSource = source["authSource"];
this.readPreference = source["readPreference"];
this.mongoSrv = source["mongoSrv"];
this.mongoAuthMechanism = source["mongoAuthMechanism"];
this.mongoReplicaUser = source["mongoReplicaUser"];
this.mongoReplicaPassword = source["mongoReplicaPassword"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {

2
go.mod
View File

@@ -14,6 +14,7 @@ require (
github.com/wailsapp/wails/v2 v2.11.0
go.mongodb.org/mongo-driver/v2 v2.5.0
golang.org/x/crypto v0.47.0
golang.org/x/text v0.33.0
modernc.org/sqlite v1.44.3
)
@@ -64,7 +65,6 @@ require (
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

View File

@@ -103,10 +103,11 @@ type withLogHint struct {
}
func (e withLogHint) Error() string {
message := normalizeErrorMessage(e.err)
if strings.TrimSpace(e.logPath) == "" {
return e.err.Error()
return message
}
return fmt.Sprintf("%s详细日志%s", e.err.Error(), e.logPath)
return fmt.Sprintf("%s详细日志%s", message, e.logPath)
}
func (e withLogHint) Unwrap() error {
@@ -128,6 +129,33 @@ func formatConnSummary(config connection.ConnectionConfig) string {
b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds",
config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds))
if len(config.Hosts) > 0 {
b.WriteString(fmt.Sprintf(" 节点数=%d", len(config.Hosts)))
}
if strings.TrimSpace(config.Topology) != "" {
b.WriteString(fmt.Sprintf(" 拓扑=%s", strings.TrimSpace(config.Topology)))
}
if strings.TrimSpace(config.URI) != "" {
b.WriteString(fmt.Sprintf(" URI=已配置(长度=%d)", len(config.URI)))
}
if strings.TrimSpace(config.MySQLReplicaUser) != "" {
b.WriteString(" MySQL从库凭据=已配置")
}
if strings.EqualFold(strings.TrimSpace(config.Type), "mongodb") {
if strings.TrimSpace(config.MongoReplicaUser) != "" {
b.WriteString(" Mongo从库凭据=已配置")
}
if strings.TrimSpace(config.ReplicaSet) != "" {
b.WriteString(fmt.Sprintf(" 副本集=%s", strings.TrimSpace(config.ReplicaSet)))
}
if strings.TrimSpace(config.ReadPreference) != "" {
b.WriteString(fmt.Sprintf(" 读偏好=%s", strings.TrimSpace(config.ReadPreference)))
}
if strings.TrimSpace(config.AuthSource) != "" {
b.WriteString(fmt.Sprintf(" 认证库=%s", strings.TrimSpace(config.AuthSource)))
}
}
if config.UseSSH {
b.WriteString(fmt.Sprintf(" SSH=%s:%d 用户=%s", config.SSH.Host, config.SSH.Port, config.SSH.User))
}

100
internal/app/error_text.go Normal file
View File

@@ -0,0 +1,100 @@
package app
import (
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
func normalizeErrorMessage(err error) string {
if err == nil {
return ""
}
return normalizeMixedEncodingText(err.Error())
}
func normalizeMixedEncodingText(text string) string {
if text == "" {
return text
}
raw := []byte(text)
output := make([]byte, 0, len(raw)+16)
suspect := make([]byte, 0, 16)
flushSuspect := func() {
if len(suspect) == 0 {
return
}
fallback := strings.ToValidUTF8(string(suspect), "<22>")
decoded, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), suspect)
if err == nil && utf8.Valid(decoded) {
candidate := string(decoded)
if scoreDecodedText(candidate) > scoreDecodedText(fallback) {
output = append(output, []byte(candidate)...)
} else {
output = append(output, []byte(fallback)...)
}
} else {
output = append(output, []byte(fallback)...)
}
suspect = suspect[:0]
}
for len(raw) > 0 {
r, size := utf8.DecodeRune(raw)
if r == utf8.RuneError && size == 1 {
suspect = append(suspect, raw[0])
raw = raw[1:]
continue
}
if isLikelyMojibakeRune(r) {
suspect = append(suspect, raw[:size]...)
} else {
flushSuspect()
output = append(output, raw[:size]...)
}
raw = raw[size:]
}
flushSuspect()
return string(output)
}
func isLikelyMojibakeRune(r rune) bool {
if r == utf8.RuneError {
return true
}
if r >= 0x00C0 && r <= 0x02FF {
return true
}
if unicode.In(r, unicode.Hebrew, unicode.Arabic, unicode.Cyrillic, unicode.Greek) {
return true
}
return false
}
func scoreDecodedText(text string) int {
score := 0
for _, r := range text {
switch {
case r == '<27>':
score -= 6
case unicode.Is(unicode.Han, r):
score += 4
case isLikelyMojibakeRune(r):
score -= 3
case unicode.IsPrint(r):
score += 1
default:
score -= 2
}
}
return score
}

View File

@@ -0,0 +1,25 @@
package app
import "testing"
func TestNormalizeMixedEncodingText_GBKErrorMessage(t *testing.T) {
raw := []byte("pq: ")
raw = append(raw, 0xD3, 0xC3, 0xBB, 0xA7) // 用户
raw = append(raw, []byte(` "root" Password `)...)
raw = append(raw, 0xC8, 0xCF, 0xD6, 0xA4, 0xCA, 0xA7, 0xB0, 0xDC) // 认证失败
raw = append(raw, []byte(" (28P01)")...)
got := normalizeMixedEncodingText(string(raw))
want := `pq: 用户 "root" Password 认证失败 (28P01)`
if got != want {
t.Fatalf("normalizeMixedEncodingText() mismatch\nwant: %q\ngot: %q", want, got)
}
}
func TestNormalizeMixedEncodingText_KeepUTF8(t *testing.T) {
input := `连接建立后验证失败pq: password authentication failed for user "root"`
got := normalizeMixedEncodingText(input)
if got != input {
t.Fatalf("expected unchanged utf8 text, got: %q", got)
}
}

View File

@@ -36,6 +36,41 @@ func (a *App) TestConnection(config connection.ConnectionConfig) connection.Quer
return connection.QueryResult{Success: true, Message: "连接成功"}
}
func (a *App) MongoDiscoverMembers(config connection.ConnectionConfig) connection.QueryResult {
config.Type = "mongodb"
dbInst, err := a.getDatabaseForcePing(config)
if err != nil {
logger.Error(err, "MongoDiscoverMembers 获取连接失败:%s", formatConnSummary(config))
return connection.QueryResult{Success: false, Message: err.Error()}
}
discoverable, ok := dbInst.(interface {
DiscoverMembers() (string, []connection.MongoMemberInfo, error)
})
if !ok {
return connection.QueryResult{Success: false, Message: "当前 MongoDB 驱动不支持成员发现"}
}
replicaSet, members, err := discoverable.DiscoverMembers()
if err != nil {
logger.Error(err, "MongoDiscoverMembers 执行失败:%s", formatConnSummary(config))
return connection.QueryResult{Success: false, Message: err.Error()}
}
data := map[string]interface{}{
"replicaSet": replicaSet,
"members": members,
}
logger.Infof("MongoDiscoverMembers 成功:%s 成员数=%d 副本集=%s", formatConnSummary(config), len(members), replicaSet)
return connection.QueryResult{
Success: true,
Message: fmt.Sprintf("发现 %d 个成员", len(members)),
Data: data,
}
}
func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) connection.QueryResult {
runConfig := config
runConfig.Database = ""

View File

@@ -11,18 +11,31 @@ type SSHConfig struct {
// ConnectionConfig holds database connection details including SSH
type ConnectionConfig struct {
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
UseSSH bool `json:"useSSH"`
SSH SSHConfig `json:"ssh"`
Driver string `json:"driver,omitempty"` // For custom connection
DSN string `json:"dsn,omitempty"` // For custom connection
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
Database string `json:"database"`
UseSSH bool `json:"useSSH"`
SSH SSHConfig `json:"ssh"`
Driver string `json:"driver,omitempty"` // For custom connection
DSN string `json:"dsn,omitempty"` // For custom connection
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
Topology string `json:"topology,omitempty"` // single | replica
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password
ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name
AuthSource string `json:"authSource,omitempty"` // MongoDB authSource
ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference
MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
}
// QueryResult is the standard response format for Wails methods
@@ -89,3 +102,12 @@ type ChangeSet struct {
Updates []UpdateRow `json:"updates"`
Deletes []map[string]interface{} `json:"deletes"`
}
type MongoMemberInfo struct {
Host string `json:"host"`
Role string `json:"role"`
State string `json:"state"`
StateCode int `json:"stateCode,omitempty"`
Healthy bool `json:"healthy"`
IsSelf bool `json:"isSelf,omitempty"`
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net"
"net/url"
"sort"
"strconv"
"strings"
"time"
@@ -26,53 +27,264 @@ type MongoDB struct {
forwarder *ssh.LocalForwarder
}
func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
// mongodb://user:password@host:port/database?authSource=admin
host := config.Host
port := config.Port
if port == 0 {
port = 27017
const defaultMongoPort = 27017
func normalizeMongoAddress(host string, port int) string {
h := strings.TrimSpace(host)
if h == "" {
h = "localhost"
}
p := port
if p <= 0 {
p = defaultMongoPort
}
return fmt.Sprintf("%s:%d", h, p)
}
func normalizeMongoSeed(raw string, defaultPort int, useSRV bool) (string, bool) {
host, port, ok := parseHostPortWithDefault(raw, defaultPort)
if !ok {
return "", false
}
uri := fmt.Sprintf("mongodb://%s:%d", host, port)
if useSRV {
normalized := strings.TrimSpace(host)
if normalized == "" {
return "", false
}
return normalized, true
}
if config.User != "" {
encodedUser := url.QueryEscape(config.User)
if config.Password != "" {
encodedPass := url.QueryEscape(config.Password)
uri = fmt.Sprintf("mongodb://%s:%s@%s:%d", encodedUser, encodedPass, host, port)
return normalizeMongoAddress(host, port), true
}
func collectMongoSeeds(config connection.ConnectionConfig) []string {
defaultPort := config.Port
if defaultPort <= 0 {
defaultPort = defaultMongoPort
}
useSRV := config.MongoSRV
candidates := make([]string, 0, len(config.Hosts)+1)
if len(config.Hosts) > 0 {
candidates = append(candidates, config.Hosts...)
} else {
if useSRV {
candidates = append(candidates, strings.TrimSpace(config.Host))
} else {
uri = fmt.Sprintf("mongodb://%s@%s:%d", encodedUser, host, port)
candidates = append(candidates, normalizeMongoAddress(config.Host, defaultPort))
}
}
// Add connection options
params := []string{}
timeout := getConnectTimeoutSeconds(config)
params = append(params, fmt.Sprintf("connectTimeoutMS=%d", timeout*1000))
params = append(params, fmt.Sprintf("serverSelectionTimeoutMS=%d", timeout*1000))
// authSource: 优先使用 config.Database为空时默认 admin
authSource := "admin"
if config.Database != "" {
authSource = config.Database
result := make([]string, 0, len(candidates))
seen := make(map[string]struct{}, len(candidates))
for _, entry := range candidates {
normalized, ok := normalizeMongoSeed(entry, defaultPort, useSRV)
if !ok {
continue
}
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
result = append(result, normalized)
}
params = append(params, fmt.Sprintf("authSource=%s", authSource))
if len(params) > 0 {
uri = uri + "/?" + strings.Join(params, "&")
return result
}
func applyMongoURI(config connection.ConnectionConfig) connection.ConnectionConfig {
uriText := strings.TrimSpace(config.URI)
if uriText == "" {
return config
}
lowerURI := strings.ToLower(uriText)
if strings.HasPrefix(lowerURI, "mongodb+srv://") {
config.MongoSRV = true
}
if !strings.HasPrefix(lowerURI, "mongodb://") && !strings.HasPrefix(lowerURI, "mongodb+srv://") {
return config
}
parsed, err := url.Parse(uriText)
if err != nil {
return config
}
if parsed.User != nil {
if config.User == "" {
config.User = parsed.User.Username()
}
if pass, ok := parsed.User.Password(); ok && config.Password == "" {
config.Password = pass
}
}
if dbName := strings.TrimPrefix(parsed.Path, "/"); dbName != "" && config.Database == "" {
config.Database = dbName
}
defaultPort := config.Port
if defaultPort <= 0 {
defaultPort = defaultMongoPort
}
hostsFromURI := make([]string, 0, 4)
hostText := strings.TrimSpace(parsed.Host)
if hostText != "" {
for _, entry := range strings.Split(hostText, ",") {
normalized, ok := normalizeMongoSeed(entry, defaultPort, config.MongoSRV)
if ok {
hostsFromURI = append(hostsFromURI, normalized)
}
}
}
if len(config.Hosts) == 0 && len(hostsFromURI) > 0 {
config.Hosts = hostsFromURI
}
if strings.TrimSpace(config.Host) == "" && len(hostsFromURI) > 0 {
host, port, ok := parseHostPortWithDefault(hostsFromURI[0], defaultPort)
if ok {
config.Host = host
config.Port = port
}
}
query := parsed.Query()
if config.AuthSource == "" {
config.AuthSource = strings.TrimSpace(query.Get("authSource"))
}
if config.ReadPreference == "" {
config.ReadPreference = strings.TrimSpace(query.Get("readPreference"))
}
if config.ReplicaSet == "" {
config.ReplicaSet = strings.TrimSpace(query.Get("replicaSet"))
}
if config.MongoAuthMechanism == "" {
config.MongoAuthMechanism = strings.TrimSpace(query.Get("authMechanism"))
}
if config.Topology == "" {
if len(config.Hosts) > 1 || strings.TrimSpace(config.ReplicaSet) != "" {
config.Topology = "replica"
} else {
config.Topology = "single"
}
}
return config
}
func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
if strings.TrimSpace(config.URI) != "" {
return strings.TrimSpace(config.URI)
}
seeds := collectMongoSeeds(config)
if len(seeds) == 0 {
if config.MongoSRV {
seed := strings.TrimSpace(config.Host)
if seed == "" {
seed = "localhost"
}
seeds = append(seeds, seed)
} else {
seeds = append(seeds, normalizeMongoAddress(config.Host, config.Port))
}
}
scheme := "mongodb"
if config.MongoSRV {
scheme = "mongodb+srv"
}
hostText := strings.Join(seeds, ",")
uri := fmt.Sprintf("%s://%s", scheme, hostText)
if config.User != "" {
encodedUser := url.PathEscape(config.User)
if config.Password != "" {
encodedPass := url.PathEscape(config.Password)
uri = fmt.Sprintf("%s://%s:%s@%s", scheme, encodedUser, encodedPass, hostText)
} else {
uri = fmt.Sprintf("%s://%s@%s", scheme, encodedUser, hostText)
}
}
path := "/"
if strings.TrimSpace(config.Database) != "" {
path = "/" + url.PathEscape(strings.TrimSpace(config.Database))
}
uri += path
params := url.Values{}
timeout := getConnectTimeoutSeconds(config)
params.Set("connectTimeoutMS", strconv.Itoa(timeout*1000))
params.Set("serverSelectionTimeoutMS", strconv.Itoa(timeout*1000))
authSource := strings.TrimSpace(config.AuthSource)
if authSource == "" && strings.TrimSpace(config.Database) != "" {
authSource = strings.TrimSpace(config.Database)
}
if authSource == "" {
authSource = "admin"
}
params.Set("authSource", authSource)
if replicaSet := strings.TrimSpace(config.ReplicaSet); replicaSet != "" {
params.Set("replicaSet", replicaSet)
}
if readPreference := strings.TrimSpace(config.ReadPreference); readPreference != "" {
params.Set("readPreference", readPreference)
}
if authMechanism := strings.TrimSpace(config.MongoAuthMechanism); authMechanism != "" {
params.Set("authMechanism", authMechanism)
}
if encoded := params.Encode(); encoded != "" {
uri += "?" + encoded
}
return uri
}
func buildMongoAuthAttempts(config connection.ConnectionConfig) []connection.ConnectionConfig {
attempts := []connection.ConnectionConfig{config}
replicaUser := strings.TrimSpace(config.MongoReplicaUser)
if replicaUser == "" {
return attempts
}
if replicaUser == strings.TrimSpace(config.User) && config.MongoReplicaPassword == config.Password {
return attempts
}
replicaConfig := config
replicaConfig.URI = ""
replicaConfig.User = replicaUser
replicaConfig.Password = config.MongoReplicaPassword
attempts = append(attempts, replicaConfig)
return attempts
}
func (m *MongoDB) Connect(config connection.ConnectionConfig) error {
var uri string
runConfig := applyMongoURI(config)
connectConfig := runConfig
if config.UseSSH {
logger.Infof("MongoDB 使用 SSH 连接:地址=%s:%d", config.Host, config.Port)
if runConfig.UseSSH && runConfig.MongoSRV {
return fmt.Errorf("MongoDB SRV 记录模式暂不支持 SSH 隧道")
}
forwarder, err := ssh.GetOrCreateLocalForwarder(config.SSH, config.Host, config.Port)
if runConfig.UseSSH {
seeds := collectMongoSeeds(runConfig)
if len(seeds) == 0 {
seeds = append(seeds, normalizeMongoAddress(runConfig.Host, runConfig.Port))
}
targetHost, targetPort, ok := parseHostPortWithDefault(seeds[0], defaultMongoPort)
if !ok {
return fmt.Errorf("MongoDB 连接失败:无效地址 %s", seeds[0])
}
logger.Infof("MongoDB 使用 SSH 连接:地址=%s:%d", targetHost, targetPort)
forwarder, err := ssh.GetOrCreateLocalForwarder(runConfig.SSH, targetHost, targetPort)
if err != nil {
return fmt.Errorf("创建 SSH 隧道失败:%w", err)
}
@@ -88,35 +300,55 @@ func (m *MongoDB) Connect(config connection.ConnectionConfig) error {
return fmt.Errorf("解析本地端口失败:%w", err)
}
localConfig := config
localConfig := runConfig
localConfig.Host = host
localConfig.Port = port
localConfig.UseSSH = false
uri = m.getURI(localConfig)
logger.Infof("MongoDB 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
} else {
uri = m.getURI(config)
localConfig.URI = ""
localConfig.Hosts = []string{normalizeMongoAddress(host, port)}
connectConfig = localConfig
logger.Infof("MongoDB 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, targetHost, targetPort)
}
m.pingTimeout = getConnectTimeout(config)
m.database = config.Database
m.pingTimeout = getConnectTimeout(connectConfig)
m.database = connectConfig.Database
if m.database == "" {
m.database = "admin"
}
clientOpts := options.Client().ApplyURI(uri)
client, err := mongo.Connect(clientOpts)
if err != nil {
return fmt.Errorf("MongoDB 连接失败:%w", err)
}
m.client = client
attemptConfigs := buildMongoAuthAttempts(connectConfig)
var errorDetails []string
for index, attemptConfig := range attemptConfigs {
authLabel := "主库凭据"
if index > 0 {
authLabel = "从库凭据"
}
if err := m.Ping(); err != nil {
return fmt.Errorf("MongoDB 连接验证失败:%w", err)
uri := m.getURI(attemptConfig)
clientOpts := options.Client().ApplyURI(uri)
client, err := mongo.Connect(clientOpts)
if err != nil {
errorDetails = append(errorDetails, fmt.Sprintf("%s连接失败: %v", authLabel, err))
continue
}
m.client = client
if err := m.Ping(); err != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
_ = client.Disconnect(ctx)
cancel()
m.client = nil
errorDetails = append(errorDetails, fmt.Sprintf("%s验证失败: %v", authLabel, err))
continue
}
return nil
}
return nil
if len(errorDetails) > 0 {
return fmt.Errorf("MongoDB 连接失败:%s", strings.Join(errorDetails, ""))
}
return fmt.Errorf("MongoDB 连接失败:无可用连接方案")
}
func (m *MongoDB) Close() error {
@@ -148,6 +380,226 @@ func (m *MongoDB) Ping() error {
return m.client.Ping(ctx, readpref.Primary())
}
func asMongoStringList(raw interface{}) []string {
values, ok := raw.(bson.A)
if !ok {
return nil
}
result := make([]string, 0, len(values))
for _, entry := range values {
text := strings.TrimSpace(fmt.Sprintf("%v", entry))
if text != "" {
result = append(result, text)
}
}
return result
}
func asMongoString(raw interface{}) string {
if raw == nil {
return ""
}
if value, ok := raw.(string); ok {
return strings.TrimSpace(value)
}
return strings.TrimSpace(fmt.Sprintf("%v", raw))
}
func asMongoInt(raw interface{}) int {
switch value := raw.(type) {
case int:
return value
case int32:
return int(value)
case int64:
return int(value)
case float32:
return int(value)
case float64:
return int(value)
default:
return 0
}
}
func asMongoBool(raw interface{}) bool {
switch value := raw.(type) {
case bool:
return value
case int:
return value != 0
case int32:
return value != 0
case int64:
return value != 0
case float32:
return value != 0
case float64:
return value != 0
default:
return false
}
}
func mongoStateByCode(code int) string {
switch code {
case 1:
return "PRIMARY"
case 2:
return "SECONDARY"
case 3:
return "RECOVERING"
case 5:
return "STARTUP2"
case 6:
return "UNKNOWN"
case 7:
return "ARBITER"
case 8:
return "DOWN"
case 9:
return "ROLLBACK"
case 10:
return "REMOVED"
default:
return "UNKNOWN"
}
}
func normalizeMongoStateLabel(state string, stateCode int) string {
normalized := strings.ToUpper(strings.TrimSpace(state))
if normalized != "" {
return normalized
}
return mongoStateByCode(stateCode)
}
func buildMembersFromReplStatus(raw bson.M) []connection.MongoMemberInfo {
items, ok := raw["members"].(bson.A)
if !ok {
return nil
}
members := make([]connection.MongoMemberInfo, 0, len(items))
for _, entry := range items {
member, ok := entry.(bson.M)
if !ok {
continue
}
host := asMongoString(member["name"])
if host == "" {
continue
}
stateCode := asMongoInt(member["state"])
state := normalizeMongoStateLabel(asMongoString(member["stateStr"]), stateCode)
members = append(members, connection.MongoMemberInfo{
Host: host,
Role: state,
State: state,
StateCode: stateCode,
Healthy: asMongoInt(member["health"]) > 0 || asMongoBool(member["health"]),
IsSelf: asMongoBool(member["self"]),
})
}
sort.Slice(members, func(i, j int) bool {
return members[i].Host < members[j].Host
})
return members
}
func buildMembersFromHello(raw bson.M) []connection.MongoMemberInfo {
hosts := asMongoStringList(raw["hosts"])
if len(hosts) == 0 {
return nil
}
primary := asMongoString(raw["primary"])
selfHost := asMongoString(raw["me"])
passiveSet := make(map[string]struct{})
for _, host := range asMongoStringList(raw["passives"]) {
passiveSet[host] = struct{}{}
}
arbiterSet := make(map[string]struct{})
for _, host := range asMongoStringList(raw["arbiters"]) {
arbiterSet[host] = struct{}{}
}
members := make([]connection.MongoMemberInfo, 0, len(hosts))
for _, host := range hosts {
state := "SECONDARY"
stateCode := 2
if host == primary {
state = "PRIMARY"
stateCode = 1
} else if _, ok := arbiterSet[host]; ok {
state = "ARBITER"
stateCode = 7
} else if _, ok := passiveSet[host]; ok {
state = "PASSIVE"
stateCode = 6
}
members = append(members, connection.MongoMemberInfo{
Host: host,
Role: state,
State: state,
StateCode: stateCode,
Healthy: true,
IsSelf: host == selfHost,
})
}
sort.Slice(members, func(i, j int) bool {
return members[i].Host < members[j].Host
})
return members
}
func (m *MongoDB) DiscoverMembers() (string, []connection.MongoMemberInfo, error) {
if m.client == nil {
return "", nil, fmt.Errorf("connection not open")
}
timeout := m.pingTimeout
if timeout <= 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
adminDB := m.client.Database("admin")
var replStatus bson.M
replErr := adminDB.RunCommand(ctx, bson.D{{Key: "replSetGetStatus", Value: 1}}).Decode(&replStatus)
if replErr == nil {
replicaSet := asMongoString(replStatus["set"])
members := buildMembersFromReplStatus(replStatus)
if len(members) > 0 {
return replicaSet, members, nil
}
}
var helloResult bson.M
helloErr := adminDB.RunCommand(ctx, bson.D{{Key: "hello", Value: 1}}).Decode(&helloResult)
if helloErr != nil {
if err := adminDB.RunCommand(ctx, bson.D{{Key: "isMaster", Value: 1}}).Decode(&helloResult); err != nil {
if replErr != nil {
return "", nil, fmt.Errorf("成员发现失败replSetGetStatus=%vhello=%v", replErr, err)
}
return "", nil, fmt.Errorf("成员发现失败hello=%w", err)
}
}
replicaSet := asMongoString(helloResult["setName"])
members := buildMembersFromHello(helloResult)
if len(members) == 0 {
if replErr != nil {
return replicaSet, nil, fmt.Errorf("未获取到成员信息replSetGetStatus=%v", replErr)
}
return replicaSet, nil, fmt.Errorf("未获取到成员信息")
}
return replicaSet, members, nil
}
// Query executes a MongoDB command and returns results
// Supports JSON format commands like: {"find": "collection", "filter": {}}
func (m *MongoDB) Query(query string) ([]map[string]interface{}, []string, error) {

View File

@@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"fmt"
"net/url"
"strconv"
"strings"
"time"
@@ -20,16 +22,161 @@ type MySQLDB struct {
pingTimeout time.Duration
}
const defaultMySQLPort = 3306
func parseHostPortWithDefault(raw string, defaultPort int) (string, int, bool) {
text := strings.TrimSpace(raw)
if text == "" {
return "", 0, false
}
if strings.HasPrefix(text, "[") {
end := strings.Index(text, "]")
if end < 0 {
return text, defaultPort, true
}
host := text[1:end]
portText := strings.TrimSpace(text[end+1:])
if strings.HasPrefix(portText, ":") {
if p, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(portText, ":"))); err == nil && p > 0 {
return host, p, true
}
}
return host, defaultPort, true
}
lastColon := strings.LastIndex(text, ":")
if lastColon > 0 && strings.Count(text, ":") == 1 {
host := strings.TrimSpace(text[:lastColon])
portText := strings.TrimSpace(text[lastColon+1:])
if host != "" {
if p, err := strconv.Atoi(portText); err == nil && p > 0 {
return host, p, true
}
return host, defaultPort, true
}
}
return text, defaultPort, true
}
func normalizeMySQLAddress(host string, port int) string {
h := strings.TrimSpace(host)
if h == "" {
h = "localhost"
}
p := port
if p <= 0 {
p = defaultMySQLPort
}
return fmt.Sprintf("%s:%d", h, p)
}
func applyMySQLURI(config connection.ConnectionConfig) connection.ConnectionConfig {
uriText := strings.TrimSpace(config.URI)
if uriText == "" {
return config
}
if !strings.HasPrefix(strings.ToLower(uriText), "mysql://") {
return config
}
parsed, err := url.Parse(uriText)
if err != nil {
return config
}
if parsed.User != nil {
if config.User == "" {
config.User = parsed.User.Username()
}
if pass, ok := parsed.User.Password(); ok && config.Password == "" {
config.Password = pass
}
}
if dbName := strings.TrimPrefix(parsed.Path, "/"); dbName != "" && config.Database == "" {
config.Database = dbName
}
defaultPort := config.Port
if defaultPort <= 0 {
defaultPort = defaultMySQLPort
}
hostsFromURI := make([]string, 0, 4)
hostText := strings.TrimSpace(parsed.Host)
if hostText != "" {
for _, entry := range strings.Split(hostText, ",") {
host, port, ok := parseHostPortWithDefault(entry, defaultPort)
if !ok {
continue
}
hostsFromURI = append(hostsFromURI, normalizeMySQLAddress(host, port))
}
}
if len(config.Hosts) == 0 && len(hostsFromURI) > 0 {
config.Hosts = hostsFromURI
}
if strings.TrimSpace(config.Host) == "" && len(hostsFromURI) > 0 {
host, port, ok := parseHostPortWithDefault(hostsFromURI[0], defaultPort)
if ok {
config.Host = host
config.Port = port
}
}
if config.Topology == "" {
topology := strings.TrimSpace(parsed.Query().Get("topology"))
if topology != "" {
config.Topology = strings.ToLower(topology)
}
}
return config
}
func collectMySQLAddresses(config connection.ConnectionConfig) []string {
defaultPort := config.Port
if defaultPort <= 0 {
defaultPort = defaultMySQLPort
}
candidates := make([]string, 0, len(config.Hosts)+1)
if len(config.Hosts) > 0 {
candidates = append(candidates, config.Hosts...)
} else {
candidates = append(candidates, normalizeMySQLAddress(config.Host, defaultPort))
}
result := make([]string, 0, len(candidates))
seen := make(map[string]struct{}, len(candidates))
for _, entry := range candidates {
host, port, ok := parseHostPortWithDefault(entry, defaultPort)
if !ok {
continue
}
normalized := normalizeMySQLAddress(host, port)
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
result = append(result, normalized)
}
return result
}
func (m *MySQLDB) getDSN(config connection.ConnectionConfig) string {
database := config.Database
protocol := "tcp"
address := fmt.Sprintf("%s:%d", config.Host, config.Port)
address := normalizeMySQLAddress(config.Host, config.Port)
if config.UseSSH {
netName, err := ssh.RegisterSSHNetwork(config.SSH)
if err == nil {
protocol = netName
address = fmt.Sprintf("%s:%d", config.Host, config.Port)
address = normalizeMySQLAddress(config.Host, config.Port)
} else {
logger.Warnf("注册 SSH 网络失败,将尝试直连:地址=%s:%d 用户=%s原因%v", config.Host, config.Port, config.User, err)
}
@@ -41,20 +188,67 @@ func (m *MySQLDB) getDSN(config connection.ConnectionConfig) string {
config.User, config.Password, protocol, address, database, timeout)
}
func (m *MySQLDB) Connect(config connection.ConnectionConfig) error {
dsn := m.getDSN(config)
db, err := sql.Open("mysql", dsn)
if err != nil {
return fmt.Errorf("打开数据库连接失败:%w", err)
}
m.conn = db
m.pingTimeout = getConnectTimeout(config)
func resolveMySQLCredential(config connection.ConnectionConfig, addressIndex int) (string, string) {
primaryUser := strings.TrimSpace(config.User)
primaryPassword := config.Password
replicaUser := strings.TrimSpace(config.MySQLReplicaUser)
replicaPassword := config.MySQLReplicaPassword
// Force verification
if err := m.Ping(); err != nil {
return fmt.Errorf("连接建立后验证失败:%w", err)
if addressIndex > 0 && replicaUser != "" {
return replicaUser, replicaPassword
}
return nil
if primaryUser == "" && replicaUser != "" {
return replicaUser, replicaPassword
}
return config.User, primaryPassword
}
func (m *MySQLDB) Connect(config connection.ConnectionConfig) error {
runConfig := applyMySQLURI(config)
addresses := collectMySQLAddresses(runConfig)
if len(addresses) == 0 {
return fmt.Errorf("连接建立后验证失败:未找到可用的 MySQL 地址")
}
var errorDetails []string
for index, address := range addresses {
candidateConfig := runConfig
host, port, ok := parseHostPortWithDefault(address, defaultMySQLPort)
if !ok {
continue
}
candidateConfig.Host = host
candidateConfig.Port = port
candidateConfig.User, candidateConfig.Password = resolveMySQLCredential(runConfig, index)
dsn := m.getDSN(candidateConfig)
db, err := sql.Open("mysql", dsn)
if err != nil {
errorDetails = append(errorDetails, fmt.Sprintf("%s 打开失败: %v", address, err))
continue
}
timeout := getConnectTimeout(candidateConfig)
ctx, cancel := utils.ContextWithTimeout(timeout)
pingErr := db.PingContext(ctx)
cancel()
if pingErr != nil {
_ = db.Close()
errorDetails = append(errorDetails, fmt.Sprintf("%s 验证失败: %v", address, pingErr))
continue
}
m.conn = db
m.pingTimeout = timeout
return nil
}
if len(errorDetails) == 0 {
return fmt.Errorf("连接建立后验证失败:未找到可用的 MySQL 地址")
}
return fmt.Errorf("连接建立后验证失败:%s", strings.Join(errorDetails, ""))
}
func (m *MySQLDB) Close() error {