diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d15af6d..fce9887 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -20,7 +20,7 @@ import SecurityUpdateSettingsModal from './components/SecurityUpdateSettingsModa
import { DEFAULT_APPEARANCE, useStore } from './store';
import { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from './types';
import { blurToFilter, isMacLikePlatform, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
-import { DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS, sanitizeDataTableColumnWidthMode } from './utils/dataGridDisplay';
+import { DENSITY_OPTIONS, sanitizeDataTableDensity } from './utils/dataGridDisplay';
import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
import { shouldEnableMacWindowDiagnostics } from './utils/macWindowDiagnostics';
import { resolveAboutDisplayVersion } from './utils/appVersionDisplay';
@@ -3477,15 +3477,15 @@ function App() {
/>
-
数据表列宽模式
+
表格密度
setAppearance({ dataTableColumnWidthMode: sanitizeDataTableColumnWidthMode(value) })}
+ options={DENSITY_OPTIONS}
+ value={appearance.dataTableDensity}
+ onChange={(value) => setAppearance({ dataTableDensity: sanitizeDataTableDensity(value) })}
/>
- 标准模式默认列宽 200px;紧凑模式默认列宽 140px。已手动拖拽调整的列宽优先保留。
+ 控制行高、列宽和内边距。舒适适合大屏细看;紧凑适合最大化信息密度。已手动拖拽的列宽优先保留。
diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx
index 4320d6a..d87e0ed 100644
--- a/frontend/src/components/DataGrid.ddl.test.tsx
+++ b/frontend/src/components/DataGrid.ddl.test.tsx
@@ -27,7 +27,7 @@ const storeState = vi.hoisted(() => ({
opacity: 1,
blur: 0,
showDataTableVerticalBorders: false,
- dataTableColumnWidthMode: 'standard',
+ dataTableDensity: 'comfortable',
},
queryOptions: {
showColumnComment: false,
diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx
index c033310..7f3d57c 100644
--- a/frontend/src/components/DataGrid.layout.test.tsx
+++ b/frontend/src/components/DataGrid.layout.test.tsx
@@ -14,7 +14,7 @@ vi.mock('../store', () => ({
opacity: 1,
blur: 0,
showDataTableVerticalBorders: false,
- dataTableColumnWidthMode: 'standard',
+ dataTableDensity: 'comfortable',
},
queryOptions: {
showColumnComment: false,
diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx
index fe8b054..d8ec3d4 100644
--- a/frontend/src/components/DataGrid.tsx
+++ b/frontend/src/components/DataGrid.tsx
@@ -23,7 +23,7 @@ import {
arrayMove
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
-import { ImportData, ExportData, ExportQuery, ApplyChanges, DBGetColumns, DBGetIndexes, DBShowCreateTable } from '../../wailsjs/go/app/App';
+import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, PreviewChanges, DBGetColumns, DBGetIndexes, DBShowCreateTable } from '../../wailsjs/go/app/App';
import ImportPreviewModal from './ImportPreviewModal';
import { useStore } from '../store';
import type { ColumnDefinition, IndexDefinition } from '../types';
@@ -34,8 +34,8 @@ import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
+ getDensityParams,
resolveDataTableColumnWidth,
- resolveDataTableDefaultColumnWidth,
resolveDataTableVerticalBorderColor,
} from '../utils/dataGridDisplay';
import { resolvePaginationPageText, resolvePaginationSummaryText, resolvePaginationTotalForControl } from '../utils/dataGridPagination';
@@ -508,7 +508,7 @@ const sortableHeaderStaticStyles = `
align-items: center;
width: 100%;
height: 100%;
- min-height: 44px;
+ min-height: var(--gonavi-header-min-height, 40px);
padding: 0 10px;
user-select: none;
cursor: inherit;
@@ -613,10 +613,20 @@ interface EditableCellProps {
handleSave: (record: Item) => void;
focusCell?: (record: Item, dataIndex: string, title: React.ReactNode) => void;
columnType?: string;
+ inputCellPadding?: React.CSSProperties;
as?: any;
+ modifiedColumns?: Record>;
+ rowKeyStr?: (k: React.Key) => string;
+ deletedRowKeys?: Set;
+ darkMode?: boolean;
[key: string]: any;
}
+// 模块级变量:绕过 React 渲染链条,在事件处理器中直接读取最新删除状态。
+// EditableCell 内部通过 React.memo 包裹,且 Ant Design rc-table 有多层 memo 缓存,
+// 仅靠 props 传递 deletedRowKeys 可能因缓存而不触发重渲染。
+let globalDeletedRowKeys: Set = new Set();
+
const EditableCell: React.FC = React.memo(({
title,
editable,
@@ -626,7 +636,12 @@ const EditableCell: React.FC = React.memo(({
handleSave,
focusCell,
columnType,
+ inputCellPadding,
as: Component = 'td',
+ modifiedColumns,
+ rowKeyStr,
+ deletedRowKeys,
+ darkMode,
...restProps
}) => {
const [editing, setEditing] = useState(false);
@@ -711,6 +726,16 @@ const EditableCell: React.FC = React.memo(({
const pickerType = getTemporalPickerType(columnType);
const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[dataIndex] || '')));
+ const isRowDeleted = deletedRowKeys && rowKeyStr && record?.[GONAVI_ROW_KEY] !== undefined
+ ? deletedRowKeys.has(rowKeyStr(record[GONAVI_ROW_KEY]))
+ : false;
+
+ const isModified = !editing && modifiedColumns && rowKeyStr && record?.[GONAVI_ROW_KEY] !== undefined
+ ? modifiedColumns[rowKeyStr(record[GONAVI_ROW_KEY])]?.has(dataIndex)
+ : false;
+
+ const modifiedStyle: React.CSSProperties = isModified ? { backgroundColor: darkMode ? 'rgba(255, 214, 102, 0.16)' : '#FFF3B0' } : {};
+
if (editable) {
childNode = editing ? (
@@ -772,6 +797,7 @@ const EditableCell: React.FC = React.memo(({
) : (
{ void save(); }}
onBlur={() => { void save(); }}
onFocus={(e) => {
@@ -795,7 +821,7 @@ const EditableCell: React.FC = React.memo(({
) : (
{children}
@@ -804,7 +830,13 @@ const EditableCell: React.FC
= React.memo(({
} else if (cellContextMenuContext) {
// 非编辑模式(只读查询结果)也绑定右键菜单,支持复制为 INSERT/JSON/CSV 等操作
childNode = (
-
+
+ {children}
+
+ );
+ } else if (isModified) {
+ childNode = (
+
{children}
);
@@ -812,6 +844,11 @@ const EditableCell: React.FC
= React.memo(({
const handleDoubleClick = () => {
if (!editable) return;
+ if (isRowDeleted) return;
+ // 模块级检查:绕过 React 渲染链条,确保即使组件因 memo 缓存未重渲染也能拿到最新状态
+ if (record?.[GONAVI_ROW_KEY] !== undefined
+ && rowKeyStr
+ && globalDeletedRowKeys.has(rowKeyStr(record[GONAVI_ROW_KEY]))) return;
// 已在编辑态时再次双击不应退出编辑;双击应支持在 Input 内进行全选。
if (editing) return;
const raw = record?.[dataIndex];
@@ -1081,8 +1118,7 @@ export const buildDataGridCommitChangeSet = ({
};
// P2 性能优化:提取内联 style 对象为模块级常量,避免每次 render 创建新对象
-const CELL_ELLIPSIS_STYLE: React.CSSProperties = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
-const VIRTUAL_CELL_WRAPPER_STYLE: React.CSSProperties = { margin: -8, padding: '8px 8px 8px 8px' };
+const CELL_ELLIPSIS_STYLE: React.CSSProperties = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, width: '100%' };
const DataGrid: React.FC = ({
data, columnNames, loading, tableName, exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false,
@@ -1113,8 +1149,17 @@ const DataGrid: React.FC = ({
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const showDataTableVerticalBorders = appearance.showDataTableVerticalBorders === true;
- const dataTableColumnWidthMode = appearance.dataTableColumnWidthMode;
- const defaultColumnWidth = resolveDataTableDefaultColumnWidth(dataTableColumnWidthMode);
+ const dataTableDensity = appearance.dataTableDensity;
+ const densityParams = useMemo(() => getDensityParams(dataTableDensity), [dataTableDensity]);
+ const virtualCellWrapperStyle = useMemo(() => ({
+ margin: -8,
+ padding: densityParams.cellPadding,
+ display: 'flex',
+ alignItems: 'center',
+ minWidth: 0,
+ }), [densityParams]);
+ const headerCellMinHeight = densityParams.headerMinHeight;
+ const inputCellPadding: React.CSSProperties = { padding: densityParams.inputCellPadding };
const dataTableVerticalBorderColor = resolveDataTableVerticalBorderColor({
darkMode,
visible: showDataTableVerticalBorders,
@@ -1732,7 +1777,7 @@ const DataGrid: React.FC = ({
= ({
= ({
{titleNode}
);
- }, [columnMetaHintColor, columnMetaTooltipColor, columnMetaMap, columnMetaMapByLowerName, showColumnComment, showColumnType]);
+ }, [columnMetaHintColor, columnMetaTooltipColor, columnMetaMap, columnMetaMapByLowerName, showColumnComment, showColumnType, densityParams]);
const closeCellEditor = useCallback(() => {
setCellEditorOpen(false);
@@ -1860,8 +1905,8 @@ const DataGrid: React.FC = ({
}
.${gridId} .ant-table-tbody > tr > td,
.${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell,
- .${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid ${dataTableVerticalBorderColor} !important; }
- .${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid ${dataTableVerticalBorderColor} !important; }
+ .${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid ${dataTableVerticalBorderColor} !important; font-size: ${densityParams.dataFontSize}px !important; vertical-align: middle !important; }
+ .${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid ${dataTableVerticalBorderColor} !important; font-size: ${densityParams.dataFontSize}px !important; }
.${gridId} .ant-table-tbody > tr > td:last-child,
.${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell:last-child,
.${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell:last-child,
@@ -1878,16 +1923,39 @@ const DataGrid: React.FC = ({
padding-right: 0 !important;
}
.${gridId} .ant-table-selection-column {
+ vertical-align: middle !important;
text-align: center !important;
padding-inline-start: 0 !important;
padding-inline-end: 0 !important;
}
+ .${gridId} .ant-table-selection-column .ant-checkbox-wrapper {
+ display: inline-flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ margin-right: 0 !important;
+ }
/* 窄表场景下 rc-table 会按视口等比放大选择列宽度,不能再额外锁死 header 宽度;
这里只统一 header/body 的内边距与对齐方式,避免第一列把后续数据列整体顶偏。 */
.${gridId} .ant-table-tbody > tr > td.ant-table-selection-column,
- .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell.ant-table-selection-column,
- .${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell.ant-table-selection-column {
+ .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell.ant-table-selection-column {
text-align: center !important;
+ vertical-align: middle !important;
+ padding-inline-start: 0 !important;
+ padding-inline-end: 0 !important;
+ padding-left: 0 !important;
+ padding-right: 0 !important;
+ }
+ .${gridId} .ant-table-tbody > tr > td.ant-table-selection-column .ant-checkbox-wrapper,
+ .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell.ant-table-selection-column .ant-checkbox-wrapper {
+ display: inline-flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ margin-right: 0 !important;
+ }
+ .${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell.ant-table-selection-column {
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
padding-inline-start: 0 !important;
padding-inline-end: 0 !important;
padding-left: 0 !important;
@@ -1919,10 +1987,14 @@ const DataGrid: React.FC = ({
.${gridId} .row-added > .ant-table-cell { background-color: ${rowAddedBg} !important; color: ${darkMode ? '#e6fffb' : 'inherit'}; }
.${gridId} .row-modified td,
.${gridId} .row-modified > .ant-table-cell { background-color: ${rowModBg} !important; color: ${darkMode ? '#e6f7ff' : 'inherit'}; }
+ .${gridId} .row-deleted td,
+ .${gridId} .row-deleted > .ant-table-cell { background-color: ${darkMode ? '#1f1f1f' : '#f0f0f0'} !important; color: ${darkMode ? '#595959' : '#bfbfbf'} !important; text-decoration: line-through; }
.${gridId} .ant-table-tbody > tr.row-added:hover > td,
.${gridId} .ant-table-tbody .ant-table-row.row-added:hover > .ant-table-cell { background-color: ${rowAddedHover} !important; }
.${gridId} .ant-table-tbody > tr.row-modified:hover > td,
.${gridId} .ant-table-tbody .ant-table-row.row-modified:hover > .ant-table-cell { background-color: ${rowModHover} !important; }
+ .${gridId} .ant-table-tbody > tr.row-deleted:hover > td,
+ .${gridId} .ant-table-tbody .ant-table-row.row-deleted:hover > .ant-table-cell { background-color: ${darkMode ? '#2a2a2a' : '#e8e8e8'} !important; }
.${gridId} .ant-table-tbody > tr > td[data-col-name],
.${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell[data-col-name] { user-select: none; -webkit-user-select: none; cursor: crosshair; }
.${gridId} .ant-table-tbody > tr > td[data-cell-selected="true"],
@@ -2310,7 +2382,7 @@ const DataGrid: React.FC = ({
justify-content: center;
line-height: 1;
}
- `, [themeStyles, gridId, tableBodyBottomPadding, darkMode, opacity, dataTableVerticalBorderColor]);
+ `, [themeStyles, gridId, tableBodyBottomPadding, darkMode, opacity, dataTableVerticalBorderColor, densityParams]);
const recalculateTableMetrics = useCallback((targetElement?: HTMLElement | null) => {
const target = targetElement || containerRef.current;
@@ -2387,6 +2459,15 @@ const DataGrid: React.FC = ({
const [addedRows, setAddedRows] = useState([]);
const [modifiedRows, setModifiedRows] = useState>({});
const [deletedRowKeys, setDeletedRowKeys] = useState>(new Set());
+ // 同步到模块级变量,确保 EditableCell 事件处理器始终读取最新删除状态
+ globalDeletedRowKeys = deletedRowKeys;
+ const [modifiedColumns, setModifiedColumns] = useState>>({});
+ const [previewModalOpen, setPreviewModalOpen] = useState(false);
+ const [previewSqlData, setPreviewSqlData] = useState<{
+ deletes: string[];
+ updates: string[];
+ inserts: string[];
+ }>({ deletes: [], updates: [], inserts: [] });
const normalizeFilterLogic = useCallback((logic: unknown): 'AND' | 'OR' => {
return String(logic || '').trim().toUpperCase() === 'OR' ? 'OR' : 'AND';
@@ -2496,6 +2577,7 @@ const DataGrid: React.FC = ({
setAddedRows([]);
setModifiedRows({});
setDeletedRowKeys(new Set());
+ setModifiedColumns({});
setSelectedRowKeys([]);
setCopiedCellPatch(null);
setCopiedRowsForPaste([]);
@@ -3087,16 +3169,18 @@ const DataGrid: React.FC = ({
}, [addedRows, rowKeyStr]);
const displayData = useMemo(() => {
- return [...data, ...addedRows].filter(item => {
- const k = item?.[GONAVI_ROW_KEY];
- return k === undefined ? true : !deletedRowKeys.has(rowKeyStr(k));
- });
- }, [data, addedRows, deletedRowKeys]);
+ return [...data, ...addedRows];
+ }, [data, addedRows]);
useEffect(() => { displayDataRef.current = displayData; }, [displayData]);
const hasChanges = addedRows.length > 0 || Object.keys(modifiedRows).length > 0 || deletedRowKeys.size > 0;
+ const allSelectedAreDeleted = useMemo(() => {
+ if (selectedRowKeys.length === 0) return false;
+ return selectedRowKeys.every(key => deletedRowKeys.has(rowKeyStr(key)));
+ }, [selectedRowKeys, deletedRowKeys, rowKeyStr]);
+
const addedRowKeySet = useMemo(() => {
const next = new Set();
addedRows.forEach((row) => {
@@ -3113,7 +3197,8 @@ const DataGrid: React.FC = ({
if (k === undefined || k === null) return '';
const keyStr = rowKeyStr(k);
if (addedRowKeySet.has(keyStr)) return 'row-added';
- if (modifiedRowKeySet.has(keyStr) || deletedRowKeys.has(keyStr)) return 'row-modified';
+ if (deletedRowKeys.has(keyStr)) return 'row-deleted';
+ if (modifiedRowKeySet.has(keyStr)) return 'row-modified';
return '';
}, [addedRowKeySet, modifiedRowKeySet, deletedRowKeys, rowKeyStr]);
@@ -3163,7 +3248,7 @@ const DataGrid: React.FC = ({
const currentWidth = resolveDataTableColumnWidth({
manualWidth: columnWidths[key],
- widthMode: dataTableColumnWidthMode,
+ density: dataTableDensity,
});
const containerLeft = containerRef.current?.getBoundingClientRect().left ?? 0;
@@ -3195,7 +3280,7 @@ const DataGrid: React.FC = ({
document.body.style.userSelect = 'none';
- }, [columnWidths, dataTableColumnWidthMode]);
+ }, [columnWidths, dataTableDensity]);
const measureTextWidth = useCallback((text: string, font: string) => {
if (typeof document === 'undefined') {
@@ -3224,6 +3309,29 @@ const DataGrid: React.FC = ({
return (text: string) => measureTextWidth(text, font);
}, [measureTextWidth]);
+ const autoFitDoneRef = useRef('');
+ useEffect(() => {
+ if (displayColumnNames.length === 0 || displayData.length === 0) return;
+ const sig = displayColumnNames.join(',');
+ if (autoFitDoneRef.current === sig) return;
+ const font = `${densityParams.dataFontSize}px -apple-system, sans-serif`;
+ const newWidths: Record = {};
+ displayColumnNames.forEach((key) => {
+ const autoWidth = calculateAutoFitColumnWidth({
+ headerTexts: [key],
+ valueTexts: displayData.slice(0, 200).map((row) => row?.[key]),
+ measureHeaderText: (t) => measureTextWidth(t, `600 ${font}`),
+ measureCellText: (t) => measureTextWidth(t, `400 ${font}`),
+ minWidth: 40,
+ maxWidth: 600,
+ defaultWidth: densityParams.defaultColumnWidth,
+ });
+ newWidths[key] = autoWidth;
+ });
+ autoFitDoneRef.current = sig;
+ setColumnWidths((prev) => ({ ...newWidths, ...prev }));
+ }, [displayColumnNames, displayData, densityParams, measureTextWidth]);
+
const handleResizeAutoFit = useCallback((key: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -3241,14 +3349,14 @@ const DataGrid: React.FC = ({
const defaultWidth = resolveDataTableColumnWidth({
manualWidth: columnWidths[key],
- widthMode: dataTableColumnWidthMode,
+ density: dataTableDensity,
});
const containerWidth = containerRef.current?.clientWidth ?? 0;
const nextWidth = calculateAutoFitColumnWidth({
headerTexts,
- valueTexts: displayDataRef.current.map((row) => row?.[key]),
- measureHeaderText: buildAutoFitMeasurer(headerEl, '600 13px sans-serif'),
- measureCellText: buildAutoFitMeasurer(sampleCell ?? null, '400 13px sans-serif'),
+ valueTexts: displayDataRef.current.slice(0, 200).map((row) => row?.[key]),
+ measureHeaderText: buildAutoFitMeasurer(headerEl, `600 ${densityParams.dataFontSize}px -apple-system, sans-serif`),
+ measureCellText: buildAutoFitMeasurer(sampleCell ?? null, `400 ${densityParams.dataFontSize}px -apple-system, sans-serif`),
defaultWidth,
minWidth: 80,
maxWidth: Math.max(720, Math.floor(containerWidth * 0.85)),
@@ -3260,7 +3368,7 @@ const DataGrid: React.FC = ({
columnMetaMap,
columnMetaMapByLowerName,
columnWidths,
- dataTableColumnWidthMode,
+ dataTableDensity,
showColumnComment,
showColumnType,
]);
@@ -3306,35 +3414,53 @@ const DataGrid: React.FC = ({
const handleCellSave = useCallback((row: any) => {
const rowKey = row?.[GONAVI_ROW_KEY];
if (rowKey === undefined) return;
+ const keyStr = rowKeyStr(rowKey);
const isAdded = addedRows.some(r => r?.[GONAVI_ROW_KEY] === rowKey);
if (isAdded) {
setAddedRows(prev => prev.map(r => r?.[GONAVI_ROW_KEY] === rowKey ? { ...r, ...row } : r));
- } else {
- // 查找原始行数据,对比是否真正有值变更
- const originalRow = data.find(r => r?.[GONAVI_ROW_KEY] === rowKey);
- if (originalRow) {
- const changedFields: Record = {};
- for (const col of Object.keys(row)) {
- if (col === GONAVI_ROW_KEY) continue;
- if (!isCellValueEqualForDiff(originalRow[col], row[col])) {
- changedFields[col] = row[col];
- }
- }
- if (Object.keys(changedFields).length === 0) {
- // 没有实际变更,从 modifiedRows 中移除该行(如有)
- setModifiedRows(prev => {
- const keyStr = rowKeyStr(rowKey);
- if (!(keyStr in prev)) return prev;
- const next = { ...prev };
- delete next[keyStr];
- return next;
- });
- return;
+ return;
+ }
+ if (deletedRowKeys.has(keyStr)) return;
+ // 查找原始行数据,对比是否真正有值变更
+ const originalRow = data.find(r => r?.[GONAVI_ROW_KEY] === rowKey);
+ if (originalRow) {
+ const changedFields: Record = {};
+ for (const col of Object.keys(row)) {
+ if (col === GONAVI_ROW_KEY) continue;
+ if (!isCellValueEqualForDiff(originalRow[col], row[col])) {
+ changedFields[col] = row[col];
}
}
- setModifiedRows(prev => ({ ...prev, [rowKeyStr(rowKey)]: row }));
+ if (Object.keys(changedFields).length === 0) {
+ // 没有实际变更,从 modifiedRows 中移除该行
+ setModifiedRows(prev => {
+ if (!(keyStr in prev)) return prev;
+ const next = { ...prev };
+ delete next[keyStr];
+ return next;
+ });
+ // 同时清除该行的 modifiedColumns
+ setModifiedColumns(prev => {
+ if (!(keyStr in prev)) return prev;
+ const next = { ...prev };
+ delete next[keyStr];
+ return next;
+ });
+ return;
+ }
+ // 更新 modifiedColumns:记录所有变更的列
+ setModifiedColumns(prev => {
+ const newCols = new Set(Object.keys(changedFields));
+ // 如果和之前一样,避免不必要的 state 更新
+ if (prev[keyStr] && prev[keyStr].size === newCols.size &&
+ [...newCols].every(c => prev[keyStr].has(c))) {
+ return prev;
+ }
+ return { ...prev, [keyStr]: newCols };
+ });
+ setModifiedRows(prev => ({ ...prev, [keyStr]: row }));
}
- }, [addedRows, data]);
+ }, [addedRows, data, rowKeyStr]);
const handleDataPanelSave = useCallback(() => {
if (!focusedCellInfo) return;
@@ -3391,12 +3517,19 @@ const DataGrid: React.FC = ({
const mergedDisplayData = useMemo(() => {
return displayData.map(row => {
const k = row?.[GONAVI_ROW_KEY];
- if (k !== undefined && modifiedRows[rowKeyStr(k)]) {
- return { ...row, ...modifiedRows[rowKeyStr(k)] };
+ const keyStr = k !== undefined ? rowKeyStr(k) : undefined;
+ let result = row;
+ if (keyStr !== undefined && modifiedRows[keyStr]) {
+ result = { ...row, ...modifiedRows[keyStr] };
}
- return row;
+ if (keyStr !== undefined && deletedRowKeys.has(keyStr)) {
+ // 为已删除行创建新对象引用,确保 Ant Design 数据源检测到变化并触发行重渲染
+ // 仅当 result 尚未被 modifiedRows 分支重新分配时才创建新引用
+ result = result === row ? { ...row } : result;
+ }
+ return result;
});
- }, [displayData, modifiedRows]);
+ }, [displayData, modifiedRows, deletedRowKeys]);
const pageFindMatches = useMemo(() => collectDataGridFindMatches(
mergedDisplayData,
@@ -3771,7 +3904,7 @@ const DataGrid: React.FC = ({
// 不使用 ellipsis,避免 Ant Design 的 Tooltip 展开行为
width: resolveDataTableColumnWidth({
manualWidth: columnWidths[key],
- widthMode: dataTableColumnWidthMode,
+ density: dataTableDensity,
}),
sorter: onSort ? { multiple: displayColumnNames.indexOf(key) + 1 } : false,
sortOrder: (sortInfo.find(s => s.columnKey === key && s.enabled !== false)?.order || null) as SortOrder | undefined,
@@ -3815,7 +3948,7 @@ const DataGrid: React.FC = ({
},
}),
}));
- }, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, handleResizeAutoFit, canModifyData, onSort, renderColumnTitle, dataTableColumnWidthMode, normalizedPageFindText]);
+ }, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, handleResizeAutoFit, canModifyData, onSort, renderColumnTitle, dataTableDensity, normalizedPageFindText]);
const mergedColumns = useMemo(() => columns.map((col): ColumnType => {
const dataIndex = String(col.dataIndex);
@@ -3844,9 +3977,15 @@ const DataGrid: React.FC = ({
cellProps.handleSave = handleCellSave;
cellProps.focusCell = openCellEditor;
cellProps.columnType = (columnMetaMap[dataIndex] || columnMetaMapByLowerName[dataIndex.toLowerCase()])?.type;
+ cellProps.inputCellPadding = inputCellPadding;
} else if (col.editable && !enableInlineEditableCell) {
- // 可编辑但非 inline(虚拟模式下):双击和右键通过 onCell 绑定
- cellProps.onDoubleClick = () => handleVirtualCellActivate(record, dataIndex, dataIndex);
+ // 可编辑但非 inline(虚拟模式下):已删除行不绑定双击处理
+ const rowDeleted = record?.[GONAVI_ROW_KEY] !== undefined
+ ? deletedRowKeys.has(rowKeyStr(record[GONAVI_ROW_KEY]))
+ : false;
+ if (!rowDeleted) {
+ cellProps.onDoubleClick = () => handleVirtualCellActivate(record, dataIndex, dataIndex);
+ }
cellProps.onContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -3860,22 +3999,33 @@ const DataGrid: React.FC = ({
showCellContextMenu(e, record, dataIndex, dataIndex);
};
}
+ cellProps.modifiedColumns = modifiedColumns;
+ cellProps.rowKeyStr = rowKeyStr;
+ cellProps.deletedRowKeys = deletedRowKeys;
+ cellProps.darkMode = darkMode;
return cellProps;
},
render: (text: any, record: Item, index: number) => {
const originalRenderContent = col.render ? (col.render as any)(text, record, index) : text;
+ const rowDeletedForRender = record?.[GONAVI_ROW_KEY] !== undefined
+ ? deletedRowKeys.has(rowKeyStr(record[GONAVI_ROW_KEY]))
+ : false;
if (enableVirtual && enableInlineEditableCell) {
return (
{originalRenderContent}
@@ -3884,7 +4034,7 @@ const DataGrid: React.FC = ({
if (enableVirtual) {
return (
{
e.preventDefault();
e.stopPropagation();
@@ -3898,7 +4048,7 @@ const DataGrid: React.FC
= ({
return originalRenderContent;
}
};
- }), [columns, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, showCellContextMenu, columnMetaMap, columnMetaMapByLowerName]);
+ }), [columns, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, showCellContextMenu, columnMetaMap, columnMetaMapByLowerName, virtualCellWrapperStyle, modifiedColumns, rowKeyStr, deletedRowKeys, darkMode]);
const handleAddRow = () => {
const newKey = `new-${Date.now()}`;
@@ -3957,14 +4107,96 @@ const DataGrid: React.FC = ({
}, [copiedRowsForPaste, displayOutputColumnNames]);
const handleDeleteSelected = () => {
+ const addedKeysToRemove: string[] = [];
+ const baseKeysToDelete: string[] = [];
+ for (const key of selectedRowKeys) {
+ const keyStr = rowKeyStr(key);
+ if (addedRowKeySet.has(keyStr)) {
+ addedKeysToRemove.push(keyStr);
+ } else if (!deletedRowKeys.has(keyStr)) {
+ baseKeysToDelete.push(keyStr);
+ }
+ }
+
+ if (addedKeysToRemove.length > 0) {
+ const removeSet = new Set(addedKeysToRemove);
+ setAddedRows(prev => prev.filter(row => {
+ const k = row?.[GONAVI_ROW_KEY];
+ return k === undefined || k === null || !removeSet.has(rowKeyStr(k));
+ }));
+ }
+ if (baseKeysToDelete.length > 0) {
+ setDeletedRowKeys(prev => {
+ const newDeleted = new Set(prev);
+ baseKeysToDelete.forEach(key => newDeleted.add(key));
+ return newDeleted;
+ });
+ }
+ setSelectedRowKeys([]);
+ };
+
+ const handleUndoDeleteSelected = () => {
setDeletedRowKeys(prev => {
const newDeleted = new Set(prev);
- selectedRowKeys.forEach(key => newDeleted.add(rowKeyStr(key)));
+ selectedRowKeys.forEach(key => newDeleted.delete(rowKeyStr(key)));
return newDeleted;
});
setSelectedRowKeys([]);
};
+ const handlePreviewChanges = useCallback(async () => {
+ if (!connectionId || !tableName) return;
+ const conn = connections.find(c => c.id === connectionId);
+ if (!conn) return;
+ const changeSetResult = buildDataGridCommitChangeSet({
+ addedRows,
+ modifiedRows,
+ deletedRowKeys,
+ data,
+ editLocator: effectiveEditLocator,
+ visibleColumnNames,
+ rowKeyToString: rowKeyStr,
+ normalizeCommitCellValue,
+ shouldCommitColumn,
+ });
+ if (!changeSetResult.ok) {
+ void message.error(changeSetResult.error || '无法构建变更集');
+ return;
+ }
+ const { changes } = changeSetResult;
+ const config = {
+ ...conn.config,
+ port: Number(conn.config.port),
+ password: conn.config.password || "",
+ database: conn.config.database || "",
+ useSSH: conn.config.useSSH || false,
+ ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
+ };
+ try {
+ const res = await PreviewChanges(buildRpcConnectionConfig(config) as any, dbName || '', tableName, {
+ inserts: changes.inserts,
+ updates: changes.updates,
+ deletes: changes.deletes,
+ locatorStrategy: effectiveEditLocator?.strategy || '',
+ } as any);
+ if (res.success) {
+ const d = res.data as { deletes: string[]; updates: string[]; inserts: string[] };
+ setPreviewSqlData({
+ deletes: d?.deletes || [],
+ updates: d?.updates || [],
+ inserts: d?.inserts || [],
+ });
+ setPreviewModalOpen(true);
+ } else {
+ void message.error(res.message || '生成预览 SQL 失败');
+ }
+ } catch (e: any) {
+ void message.error('生成预览 SQL 失败:' + (e?.message || e));
+ }
+ }, [addedRows, modifiedRows, deletedRowKeys, data, effectiveEditLocator,
+ visibleColumnNames, rowKeyStr, normalizeCommitCellValue, shouldCommitColumn,
+ connectionId, tableName, connections]);
+
const handleCommit = async () => {
if (!connectionId || !tableName) return;
const conn = connections.find(c => c.id === connectionId);
@@ -4024,6 +4256,7 @@ const DataGrid: React.FC = ({
setAddedRows([]);
setModifiedRows({});
setDeletedRowKeys(new Set());
+ setModifiedColumns({});
if (onReload) onReload();
} else {
addSqlLog({
@@ -4795,11 +5028,16 @@ const DataGrid: React.FC = ({
selectedRowKeys,
onChange: setSelectedRowKeys,
columnWidth: selectionColumnWidth,
+ renderCell: (_checked: boolean, _record: any, _index: number, originNode: React.ReactNode) => (
+
+ {originNode}
+
+ ),
}), [selectedRowKeys, selectionColumnWidth]);
const rowPropsFactory = useCallback((record: any) => ({ record } as any), []);
- const totalWidth = columns.reduce((sum: number, col: any) => sum + (Number(col.width) || defaultColumnWidth), 0) + selectionColumnWidth;
+ const totalWidth = columns.reduce((sum: number, col: any) => sum + (Number(col.width) || densityParams.defaultColumnWidth), 0) + selectionColumnWidth;
const useContextMenuRow = false;
const tableScrollX = useMemo(() => {
// rc-table 在 scroll.x 小于容器宽度时会把实际列宽按视口补齐。
@@ -5384,7 +5622,7 @@ const DataGrid: React.FC = ({
}, [pagination, onPageChange]);
return (
-
+
{/* Toolbar + Filter Panel */}
@@ -5426,7 +5664,11 @@ const DataGrid: React.FC
= ({
>
{copiedRowsForPaste.length > 0 ? `粘贴行 (${copiedRowsForPaste.length})` : '粘贴行'}
- } danger disabled={selectedRowKeys.length === 0} onClick={handleDeleteSelected}>删除选中
+ {allSelectedAreDeleted ? (
+ } disabled={selectedRowKeys.length === 0} onClick={handleUndoDeleteSelected}>撤销删除
+ ) : (
+ } danger disabled={selectedRowKeys.length === 0} onClick={handleDeleteSelected}>删除选中
+ )}
{selectedRowKeys.length > 0 && 已选 {selectedRowKeys.length}}
+ {hasChanges && (
+ ,
+ onClick: handlePreviewChanges,
+ }
+ ] }}>
+ }>预览SQL
+
+ )}
{hasChanges && (} onClick={() => {
setAddedRows([]);
setModifiedRows({});
setDeletedRowKeys(new Set());
+ setModifiedColumns({});
}}>回滚)}
>
)}
@@ -6676,6 +6931,96 @@ const DataGrid: React.FC = ({
}}
/>
+ {/* Preview SQL Modal */}
+ setPreviewModalOpen(false)}
+ width={800}
+ footer={null}
+ >
+
+ {previewSqlData.deletes.length > 0 && (
+
+
+ DELETE ({previewSqlData.deletes.length})
+
+ {previewSqlData.deletes.map((sql, i) => (
+
+
{sql}
+
}
+ style={{ position: 'absolute', top: 4, right: 4 }}
+ onClick={() => { navigator.clipboard.writeText(sql).then(() => message.success('已复制')); }}
+ />
+
+ ))}
+
+ )}
+ {previewSqlData.updates.length > 0 && (
+
+
+ UPDATE ({previewSqlData.updates.length})
+
+ {previewSqlData.updates.map((sql, i) => (
+
+
{sql}
+
}
+ style={{ position: 'absolute', top: 4, right: 4 }}
+ onClick={() => { navigator.clipboard.writeText(sql).then(() => message.success('已复制')); }}
+ />
+
+ ))}
+
+ )}
+ {previewSqlData.inserts.length > 0 && (
+
+
+ INSERT ({previewSqlData.inserts.length})
+
+ {previewSqlData.inserts.map((sql, i) => (
+
+
{sql}
+
}
+ style={{ position: 'absolute', top: 4, right: 4 }}
+ onClick={() => { navigator.clipboard.writeText(sql).then(() => message.success('已复制')); }}
+ />
+
+ ))}
+
+ )}
+ {previewSqlData.deletes.length === 0 && previewSqlData.updates.length === 0 && previewSqlData.inserts.length === 0 && (
+
无变更
+ )}
+
+
+ 共 {previewSqlData.deletes.length} 条 DELETE,{previewSqlData.updates.length} 条 UPDATE,{previewSqlData.inserts.length} 条 INSERT
+
+
+
{/* Import Preview Modal */}
{
expect(appearance.blur).toBe(6);
expect(appearance.useNativeMacWindowControls).toBe(true);
expect(appearance.showDataTableVerticalBorders).toBe(false);
- expect(appearance.dataTableColumnWidthMode).toBe('standard');
+ expect(appearance.dataTableDensity).toBe('comfortable');
});
it('persists DataGrid appearance settings and restores them after reload', async () => {
@@ -77,19 +77,19 @@ describe('store appearance persistence', () => {
useStore.getState().setAppearance({
showDataTableVerticalBorders: true,
- dataTableColumnWidthMode: 'compact',
+ dataTableDensity: 'compact',
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.appearance.showDataTableVerticalBorders).toBe(true);
- expect(persisted.state.appearance.dataTableColumnWidthMode).toBe('compact');
+ expect(persisted.state.appearance.dataTableDensity).toBe('compact');
vi.resetModules();
const reloaded = await importStore();
const appearance = reloaded.useStore.getState().appearance;
expect(appearance.showDataTableVerticalBorders).toBe(true);
- expect(appearance.dataTableColumnWidthMode).toBe('compact');
+ expect(appearance.dataTableDensity).toBe('compact');
});
it('does not clear persisted legacy connections during hydration migration', async () => {
diff --git a/frontend/src/store.ts b/frontend/src/store.ts
index 81221b5..e085ffb 100644
--- a/frontend/src/store.ts
+++ b/frontend/src/store.ts
@@ -1193,7 +1193,7 @@ const sanitizeAppearance = (
: DEFAULT_APPEARANCE.useNativeMacWindowControls,
showDataTableVerticalBorders:
dataGridDisplaySettings.showDataTableVerticalBorders,
- dataTableColumnWidthMode: dataGridDisplaySettings.dataTableColumnWidthMode,
+ dataTableDensity: dataGridDisplaySettings.dataTableDensity,
};
if (version < 2 && isLegacyDefaultAppearance(appearance)) {
return { ...DEFAULT_APPEARANCE };
diff --git a/frontend/src/utils/dataGridDisplay.test.ts b/frontend/src/utils/dataGridDisplay.test.ts
index 0f7e47b..fe7fd84 100644
--- a/frontend/src/utils/dataGridDisplay.test.ts
+++ b/frontend/src/utils/dataGridDisplay.test.ts
@@ -11,17 +11,18 @@ import {
describe('dataGridDisplay helpers', () => {
it('sanitizes missing display settings to safe defaults', () => {
expect(sanitizeDataGridDisplaySettings(undefined)).toEqual(DEFAULT_DATA_GRID_DISPLAY_SETTINGS);
- expect(sanitizeDataGridDisplaySettings({ dataTableColumnWidthMode: 'invalid' as never })).toEqual(DEFAULT_DATA_GRID_DISPLAY_SETTINGS);
+ expect(sanitizeDataGridDisplaySettings({ dataTableDensity: 'invalid' as never })).toEqual(DEFAULT_DATA_GRID_DISPLAY_SETTINGS);
});
- it('resolves standard and compact default column widths', () => {
- expect(resolveDataTableDefaultColumnWidth('standard')).toBe(200);
- expect(resolveDataTableDefaultColumnWidth('compact')).toBe(140);
+ it('resolves density-based default column widths', () => {
+ expect(resolveDataTableDefaultColumnWidth('comfortable')).toBe(180);
+ expect(resolveDataTableDefaultColumnWidth('standard')).toBe(140);
+ expect(resolveDataTableDefaultColumnWidth('compact')).toBe(100);
});
- it('keeps manual column widths ahead of mode defaults', () => {
- expect(resolveDataTableColumnWidth({ manualWidth: 320, widthMode: 'compact' })).toBe(320);
- expect(resolveDataTableColumnWidth({ manualWidth: undefined, widthMode: 'compact' })).toBe(140);
+ it('keeps manual column widths ahead of density defaults', () => {
+ expect(resolveDataTableColumnWidth({ manualWidth: 320, density: 'compact' })).toBe(320);
+ expect(resolveDataTableColumnWidth({ manualWidth: undefined, density: 'compact' })).toBe(100);
});
it('uses subtle themed vertical border colors and transparent when disabled', () => {
diff --git a/frontend/src/utils/dataGridDisplay.ts b/frontend/src/utils/dataGridDisplay.ts
index 32ed056..b0b32e0 100644
--- a/frontend/src/utils/dataGridDisplay.ts
+++ b/frontend/src/utils/dataGridDisplay.ts
@@ -1,25 +1,64 @@
-export type DataTableColumnWidthMode = 'standard' | 'compact';
+export type DataTableDensity = 'comfortable' | 'standard' | 'compact';
export interface DataGridDisplaySettings {
showDataTableVerticalBorders: boolean;
- dataTableColumnWidthMode: DataTableColumnWidthMode;
+ dataTableDensity: DataTableDensity;
}
export const DEFAULT_DATA_GRID_DISPLAY_SETTINGS: DataGridDisplaySettings = {
showDataTableVerticalBorders: false,
- dataTableColumnWidthMode: 'standard',
+ dataTableDensity: 'comfortable',
};
-export const DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS = [
- { label: '标准 200px', value: 'standard' as const },
- { label: '紧凑 140px', value: 'compact' as const },
+interface DensityParams {
+ defaultColumnWidth: number;
+ cellPadding: string;
+ inputCellPadding: string;
+ headerMinHeight: number;
+ dataFontSize: number;
+ metaFontSize: number;
+}
+
+const DENSITY_PARAMS: Record = {
+ comfortable: {
+ defaultColumnWidth: 180,
+ cellPadding: '8px',
+ inputCellPadding: '0px 4px',
+ headerMinHeight: 40,
+ dataFontSize: 13,
+ metaFontSize: 11,
+ },
+ standard: {
+ defaultColumnWidth: 140,
+ cellPadding: '5px 8px',
+ inputCellPadding: '0px 3px',
+ headerMinHeight: 34,
+ dataFontSize: 13,
+ metaFontSize: 10,
+ },
+ compact: {
+ defaultColumnWidth: 100,
+ cellPadding: '2px 6px',
+ inputCellPadding: '0px 2px',
+ headerMinHeight: 28,
+ dataFontSize: 12,
+ metaFontSize: 10,
+ },
+};
+
+export const DENSITY_OPTIONS = [
+ { label: '舒适', value: 'comfortable' as const },
+ { label: '标准', value: 'standard' as const },
+ { label: '紧凑', value: 'compact' as const },
];
-const STANDARD_DATA_TABLE_COLUMN_WIDTH = 200;
-const COMPACT_DATA_TABLE_COLUMN_WIDTH = 140;
+export const sanitizeDataTableDensity = (value: unknown): DataTableDensity => {
+ if (value === 'standard' || value === 'compact') return value;
+ return 'comfortable';
+};
-export const sanitizeDataTableColumnWidthMode = (value: unknown): DataTableColumnWidthMode => {
- return value === 'compact' ? 'compact' : 'standard';
+export const getDensityParams = (density: DataTableDensity): DensityParams => {
+ return DENSITY_PARAMS[density] || DENSITY_PARAMS.comfortable;
};
export const sanitizeDataGridDisplaySettings = (
@@ -31,30 +70,28 @@ export const sanitizeDataGridDisplaySettings = (
return {
showDataTableVerticalBorders: value.showDataTableVerticalBorders === true,
- dataTableColumnWidthMode: sanitizeDataTableColumnWidthMode(value.dataTableColumnWidthMode),
+ dataTableDensity: sanitizeDataTableDensity(value.dataTableDensity),
};
};
export const resolveDataTableDefaultColumnWidth = (
- widthMode: DataTableColumnWidthMode | null | undefined
+ density: DataTableDensity | null | undefined
): number => {
- return sanitizeDataTableColumnWidthMode(widthMode) === 'compact'
- ? COMPACT_DATA_TABLE_COLUMN_WIDTH
- : STANDARD_DATA_TABLE_COLUMN_WIDTH;
+ return getDensityParams(sanitizeDataTableDensity(density)).defaultColumnWidth;
};
export const resolveDataTableColumnWidth = ({
manualWidth,
- widthMode,
+ density,
}: {
manualWidth: number | null | undefined;
- widthMode: DataTableColumnWidthMode | null | undefined;
+ density: DataTableDensity | null | undefined;
}): number => {
if (typeof manualWidth === 'number' && Number.isFinite(manualWidth) && manualWidth > 0) {
return manualWidth;
}
- return resolveDataTableDefaultColumnWidth(widthMode);
+ return resolveDataTableDefaultColumnWidth(density);
};
export const resolveDataTableVerticalBorderColor = ({
diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts
index 5de3eeb..c30e837 100755
--- a/frontend/wailsjs/go/app/App.d.ts
+++ b/frontend/wailsjs/go/app/App.d.ts
@@ -180,6 +180,8 @@ export function OpenDriverDownloadDirectory(arg1:string):Promise;
+export function PreviewChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise;
+
export function PreviewImportFile(arg1:string):Promise;
export function ReadSQLFile(arg1:string):Promise;
diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js
index 48a06b6..0f60a94 100755
--- a/frontend/wailsjs/go/app/App.js
+++ b/frontend/wailsjs/go/app/App.js
@@ -350,6 +350,10 @@ export function OpenSQLFile() {
return window['go']['app']['App']['OpenSQLFile']();
}
+export function PreviewChanges(arg1, arg2, arg3, arg4) {
+ return window['go']['app']['App']['PreviewChanges'](arg1, arg2, arg3, arg4);
+}
+
export function PreviewImportFile(arg1) {
return window['go']['app']['App']['PreviewImportFile'](arg1);
}
diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go
index c5e33fb..c9f2c2c 100644
--- a/internal/app/methods_file.go
+++ b/internal/app/methods_file.go
@@ -1012,6 +1012,35 @@ func (a *App) ApplyChanges(config connection.ConnectionConfig, dbName, tableName
return connection.QueryResult{Success: false, Message: "当前数据库类型不支持批量提交"}
}
+// ChangePreview 变更预览结果
+type ChangePreview struct {
+ Deletes []string `json:"deletes"`
+ Updates []string `json:"updates"`
+ Inserts []string `json:"inserts"`
+}
+
+func (a *App) PreviewChanges(config connection.ConnectionConfig, dbName, tableName string, changes connection.ChangeSet) connection.QueryResult {
+ runConfig := normalizeRunConfig(config, dbName)
+
+ dbInst, err := a.getDatabase(runConfig)
+ if err != nil {
+ return connection.QueryResult{Success: false, Message: err.Error()}
+ }
+
+ var cp ChangePreview
+ // 优先使用驱动的 PreviewChanges(若实现了 ChangePreviewer 接口)
+ if previewer, ok := dbInst.(db.ChangePreviewer); ok {
+ deletes, updates, inserts := previewer.PreviewChanges(tableName, changes)
+ cp = ChangePreview{Deletes: deletes, Updates: updates, Inserts: inserts}
+ } else {
+ // 回退到通用生成,使用 quoteIdentByType 处理标识符转义
+ quoter := func(s string) string { return quoteIdentByType(runConfig.Type, s) }
+ deletes, updates, inserts := db.GenerateChangePreview(tableName, changes, quoter)
+ cp = ChangePreview{Deletes: deletes, Updates: updates, Inserts: inserts}
+ }
+ return connection.QueryResult{Success: true, Data: cp}
+}
+
func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tableName string, format string) connection.QueryResult {
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: fmt.Sprintf("Export %s", tableName),
diff --git a/internal/db/change_preview.go b/internal/db/change_preview.go
new file mode 100644
index 0000000..4f1077f
--- /dev/null
+++ b/internal/db/change_preview.go
@@ -0,0 +1,135 @@
+package db
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "GoNavi-Wails/internal/connection"
+)
+
+// GenerateChangePreview 将 ChangeSet 转为可读 SQL 语句(不执行)。
+// quoteIdent 用于引用列名/表名(MySQL: backtick, PostgreSQL: double quote)。
+func GenerateChangePreview(tableName string, changes connection.ChangeSet, quoteIdent func(string) string) (deletes, updates, inserts []string) {
+ qt := quoteIdent
+
+ // Deletes
+ for _, pk := range changes.Deletes {
+ var conds []string
+ for _, k := range sortedKeys(pk) {
+ v := pk[k]
+ conds = append(conds, fmt.Sprintf("%s = %s", qt(k), formatLiteral(v)))
+ }
+ if len(conds) > 0 {
+ deletes = append(deletes, fmt.Sprintf("DELETE FROM %s WHERE %s;", qt(tableName), strings.Join(conds, " AND ")))
+ }
+ }
+
+ // Updates
+ for _, row := range changes.Updates {
+ var sets []string
+ for _, k := range sortedKeys(row.Values) {
+ v := row.Values[k]
+ sets = append(sets, fmt.Sprintf("%s = %s", qt(k), formatLiteral(v)))
+ }
+ if len(sets) == 0 {
+ continue
+ }
+ var conds []string
+ for _, k := range sortedKeys(row.Keys) {
+ v := row.Keys[k]
+ conds = append(conds, fmt.Sprintf("%s = %s", qt(k), formatLiteral(v)))
+ }
+ if len(conds) == 0 {
+ continue
+ }
+ updates = append(updates, fmt.Sprintf("UPDATE %s SET %s WHERE %s;", qt(tableName), strings.Join(sets, ", "), strings.Join(conds, " AND ")))
+ }
+
+ // Inserts
+ for _, row := range changes.Inserts {
+ var cols []string
+ var vals []string
+ for _, k := range sortedKeys(row) {
+ v := row[k]
+ if v == nil {
+ continue
+ }
+ cols = append(cols, qt(k))
+ vals = append(vals, formatLiteral(v))
+ }
+ if len(cols) == 0 {
+ continue
+ }
+ inserts = append(inserts, fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);", qt(tableName), strings.Join(cols, ", "), strings.Join(vals, ", ")))
+ }
+
+ return deletes, updates, inserts
+}
+
+// sortedKeys 返回 map 的键排序切片,保证输出确定性。
+func sortedKeys(m map[string]interface{}) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+// formatLiteral 将 Go 值转为 SQL 字面量字符串。
+func formatLiteral(v interface{}) string {
+ if v == nil {
+ return "NULL"
+ }
+ switch t := v.(type) {
+ case string:
+ escaped := strings.ReplaceAll(t, "\\", "\\\\")
+ escaped = strings.ReplaceAll(escaped, "'", "\\'")
+ return fmt.Sprintf("'%s'", escaped)
+ case float64:
+ return formatNumber(t)
+ case float32:
+ return formatNumber(float64(t))
+ case int:
+ return fmt.Sprintf("%d", t)
+ case int64:
+ return fmt.Sprintf("%d", t)
+ case int32:
+ return fmt.Sprintf("%d", t)
+ case int16:
+ return fmt.Sprintf("%d", t)
+ case int8:
+ return fmt.Sprintf("%d", t)
+ case uint64:
+ return fmt.Sprintf("%d", t)
+ case uint32:
+ return fmt.Sprintf("%d", t)
+ case uint16:
+ return fmt.Sprintf("%d", t)
+ case uint8:
+ return fmt.Sprintf("%d", t)
+ case uint:
+ return fmt.Sprintf("%d", t)
+ case time.Time:
+ return fmt.Sprintf("'%s'", t.Format("2006-01-02 15:04:05"))
+ case bool:
+ if t {
+ return "TRUE"
+ }
+ return "FALSE"
+ case []byte:
+ return formatLiteral(string(t))
+ default:
+ escaped := strings.ReplaceAll(fmt.Sprintf("%v", t), "'", "\\'")
+ return fmt.Sprintf("'%s'", escaped)
+ }
+}
+
+func formatNumber(f float64) string {
+ if f == float64(int64(f)) {
+ return fmt.Sprintf("%d", int64(f))
+ }
+ return fmt.Sprintf("%v", f)
+}
diff --git a/internal/db/change_preview_test.go b/internal/db/change_preview_test.go
new file mode 100644
index 0000000..a817a6e
--- /dev/null
+++ b/internal/db/change_preview_test.go
@@ -0,0 +1,113 @@
+package db
+
+import (
+ "strings"
+ "testing"
+
+ "GoNavi-Wails/internal/connection"
+)
+
+func TestGenerateChangePreview_Inserts(t *testing.T) {
+ changes := connection.ChangeSet{
+ Inserts: []map[string]interface{}{
+ {"name": "alice", "age": float64(30)},
+ {"name": "bob", "age": nil},
+ },
+ }
+ deletes, updates, inserts := GenerateChangePreview("users", changes, mysqlQuote)
+ if len(inserts) != 2 {
+ t.Fatalf("expected 2 inserts, got %d", len(inserts))
+ }
+ expected1 := "INSERT INTO `users` (`age`, `name`) VALUES (30, 'alice');"
+ expected2 := "INSERT INTO `users` (`name`) VALUES ('bob');"
+ if inserts[0] != expected1 {
+ t.Errorf("insert[0]: got %s\nwant %s", inserts[0], expected1)
+ }
+ if inserts[1] != expected2 {
+ t.Errorf("insert[1]: got %s\nwant %s", inserts[1], expected2)
+ }
+ if len(deletes) != 0 || len(updates) != 0 {
+ t.Errorf("expected empty deletes/updates")
+ }
+}
+
+func TestGenerateChangePreview_Deletes(t *testing.T) {
+ changes := connection.ChangeSet{
+ Deletes: []map[string]interface{}{
+ {"id": float64(1), "name": "alice"},
+ {"id": float64(2)},
+ },
+ }
+ deletes, updates, inserts := GenerateChangePreview("users", changes, mysqlQuote)
+ if len(deletes) != 2 {
+ t.Fatalf("expected 2 deletes, got %d", len(deletes))
+ }
+ expected1 := "DELETE FROM `users` WHERE `id` = 1 AND `name` = 'alice';"
+ if deletes[0] != expected1 {
+ t.Errorf("delete[0]: got %s\nwant %s", deletes[0], expected1)
+ }
+ if len(updates) != 0 || len(inserts) != 0 {
+ t.Errorf("expected empty updates/inserts")
+ }
+}
+
+func TestGenerateChangePreview_Updates(t *testing.T) {
+ changes := connection.ChangeSet{
+ Updates: []connection.UpdateRow{
+ {
+ Keys: map[string]interface{}{"id": float64(1)},
+ Values: map[string]interface{}{"name": "charlie", "age": float64(25)},
+ },
+ },
+ }
+ deletes, updates, inserts := GenerateChangePreview("users", changes, mysqlQuote)
+ if len(updates) != 1 {
+ t.Fatalf("expected 1 update, got %d", len(updates))
+ }
+ // SET clause column order is map-iteration-based, so check substring presence
+ if !strings.Contains(updates[0], "UPDATE `users` SET") {
+ t.Errorf("update: missing UPDATE clause: %s", updates[0])
+ }
+ if !strings.Contains(updates[0], "WHERE `id` = 1") {
+ t.Errorf("update: missing WHERE clause: %s", updates[0])
+ }
+ if !strings.Contains(updates[0], "`name` = 'charlie'") {
+ t.Errorf("update: missing name set: %s", updates[0])
+ }
+ if !strings.Contains(updates[0], "`age` = 25") {
+ t.Errorf("update: missing age set: %s", updates[0])
+ }
+ if len(deletes) != 0 || len(inserts) != 0 {
+ t.Errorf("expected empty deletes/inserts")
+ }
+}
+
+func TestGenerateChangePreview_EmptyChanges(t *testing.T) {
+ deletes, updates, inserts := GenerateChangePreview("t", connection.ChangeSet{}, mysqlQuote)
+ if len(deletes) != 0 || len(updates) != 0 || len(inserts) != 0 {
+ t.Error("expected all empty for empty changeset")
+ }
+}
+
+func TestFormatLiteral(t *testing.T) {
+ cases := []struct {
+ val interface{}
+ expected string
+ }{
+ {nil, "NULL"},
+ {"hello", "'hello'"},
+ {"it's a test", "'it\\'s a test'"},
+ {float64(42), "42"},
+ {int64(-1), "-1"},
+ {true, "TRUE"},
+ {false, "FALSE"},
+ }
+ for _, c := range cases {
+ got := formatLiteral(c.val)
+ if got != c.expected {
+ t.Errorf("formatLiteral(%v): got %s, want %s", c.val, got, c.expected)
+ }
+ }
+}
+
+func mysqlQuote(s string) string { return "`" + s + "`" }
diff --git a/internal/db/database.go b/internal/db/database.go
index ec701af..7bec233 100644
--- a/internal/db/database.go
+++ b/internal/db/database.go
@@ -65,6 +65,12 @@ type BatchApplier interface {
ApplyChanges(tableName string, changes connection.ChangeSet) error
}
+// ChangePreviewer 是可选的变更预览接口。
+// 驱动可实现此接口提供自定义 SQL 预览格式;若未实现,调用方回退到 GenerateChangePreview。
+type ChangePreviewer interface {
+ PreviewChanges(tableName string, changes connection.ChangeSet) (deletes, updates, inserts []string)
+}
+
func requireSingleRowAffected(result sql.Result, action string) error {
affected, err := result.RowsAffected()
if err != nil {