️ 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();