feat(data-grid): 增强数据表编辑与展示体验

- 新增变更预览能力,支持在提交前查看删除、更新和新增对应的 SQL 语句
- 增加表格密度配置,统一控制默认列宽、行高、字号与单元格内边距
- 优化 DataGrid 编辑状态展示,区分新增、修改和删除行列的视觉反馈
- 调整导出入口与 Wails 前端绑定,补齐变更预览相关调用与测试覆盖
This commit is contained in:
TonyJiangWJ
2026-05-10 19:00:47 +08:00
parent c0ae40c638
commit 1965564386
15 changed files with 780 additions and 108 deletions

View File

@@ -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() {
/>
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<Segmented
block
options={DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS}
value={appearance.dataTableColumnWidthMode}
onChange={(value) => setAppearance({ dataTableColumnWidthMode: sanitizeDataTableColumnWidthMode(value) })}
options={DENSITY_OPTIONS}
value={appearance.dataTableDensity}
onChange={(value) => setAppearance({ dataTableDensity: sanitizeDataTableDensity(value) })}
/>
<div style={{ ...utilityMutedTextStyle, marginTop: 8 }}>
200px 140px
</div>
</div>
</div>

View File

@@ -27,7 +27,7 @@ const storeState = vi.hoisted(() => ({
opacity: 1,
blur: 0,
showDataTableVerticalBorders: false,
dataTableColumnWidthMode: 'standard',
dataTableDensity: 'comfortable',
},
queryOptions: {
showColumnComment: false,

View File

@@ -14,7 +14,7 @@ vi.mock('../store', () => ({
opacity: 1,
blur: 0,
showDataTableVerticalBorders: false,
dataTableColumnWidthMode: 'standard',
dataTableDensity: 'comfortable',
},
queryOptions: {
showColumnComment: false,

View File

@@ -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<string, Set<string>>;
rowKeyStr?: (k: React.Key) => string;
deletedRowKeys?: Set<string>;
darkMode?: boolean;
[key: string]: any;
}
// 模块级变量:绕过 React 渲染链条,在事件处理器中直接读取最新删除状态。
// EditableCell 内部通过 React.memo 包裹,且 Ant Design rc-table 有多层 memo 缓存,
// 仅靠 props 传递 deletedRowKeys 可能因缓存而不触发重渲染。
let globalDeletedRowKeys: Set<string> = new Set();
const EditableCell: React.FC<EditableCellProps> = React.memo(({
title,
editable,
@@ -626,7 +636,12 @@ const EditableCell: React.FC<EditableCellProps> = 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<EditableCellProps> = 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 ? (
<Form.Item style={{ margin: 0 }} name={getCellFieldName(record, dataIndex)}>
@@ -772,6 +797,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
) : (
<Input
ref={inputRef}
style={inputCellPadding}
onPressEnter={() => { void save(); }}
onBlur={() => { void save(); }}
onFocus={(e) => {
@@ -795,7 +821,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
) : (
<div
className="editable-cell-value-wrap"
style={{ paddingRight: 24, minHeight: 20, position: 'relative' }}
style={{ paddingRight: 0, minHeight: 20, position: 'relative', width: '100%', minWidth: 0, display: 'flex', alignItems: 'center', ...modifiedStyle }}
onContextMenu={handleContextMenu}
>
{children}
@@ -804,7 +830,13 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
} else if (cellContextMenuContext) {
// 非编辑模式(只读查询结果)也绑定右键菜单,支持复制为 INSERT/JSON/CSV 等操作
childNode = (
<div onContextMenu={handleContextMenu} style={{ minHeight: 20 }}>
<div onContextMenu={handleContextMenu} style={{ minHeight: 20, display: 'flex', alignItems: 'center', width: '100%', minWidth: 0, ...modifiedStyle }}>
{children}
</div>
);
} else if (isModified) {
childNode = (
<div style={modifiedStyle}>
{children}
</div>
);
@@ -812,6 +844,11 @@ const EditableCell: React.FC<EditableCellProps> = 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<DataGridProps> = ({
data, columnNames, loading, tableName, exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false,
@@ -1113,8 +1149,17 @@ const DataGrid: React.FC<DataGridProps> = ({
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<React.CSSProperties>(() => ({
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<DataGridProps> = ({
<span
style={{
marginTop: 2,
fontSize: 11,
fontSize: densityParams.metaFontSize,
color: columnMetaHintColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
@@ -1747,7 +1792,7 @@ const DataGrid: React.FC<DataGridProps> = ({
<span
style={{
marginTop: 2,
fontSize: 11,
fontSize: densityParams.metaFontSize,
color: columnMetaHintColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
@@ -1771,7 +1816,7 @@ const DataGrid: React.FC<DataGridProps> = ({
<span style={{ display: 'inline-flex', maxWidth: '100%' }}>{titleNode}</span>
</Tooltip>
);
}, [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<DataGridProps> = ({
}
.${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<DataGridProps> = ({
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<DataGridProps> = ({
.${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<DataGridProps> = ({
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<DataGridProps> = ({
const [addedRows, setAddedRows] = useState<any[]>([]);
const [modifiedRows, setModifiedRows] = useState<Record<string, any>>({});
const [deletedRowKeys, setDeletedRowKeys] = useState<Set<string>>(new Set());
// 同步到模块级变量,确保 EditableCell 事件处理器始终读取最新删除状态
globalDeletedRowKeys = deletedRowKeys;
const [modifiedColumns, setModifiedColumns] = useState<Record<string, Set<string>>>({});
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<DataGridProps> = ({
setAddedRows([]);
setModifiedRows({});
setDeletedRowKeys(new Set());
setModifiedColumns({});
setSelectedRowKeys([]);
setCopiedCellPatch(null);
setCopiedRowsForPaste([]);
@@ -3087,16 +3169,18 @@ const DataGrid: React.FC<DataGridProps> = ({
}, [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<string>();
addedRows.forEach((row) => {
@@ -3113,7 +3197,8 @@ const DataGrid: React.FC<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
return (text: string) => measureTextWidth(text, font);
}, [measureTextWidth]);
const autoFitDoneRef = useRef<string>('');
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<string, number> = {};
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<DataGridProps> = ({
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<DataGridProps> = ({
columnMetaMap,
columnMetaMapByLowerName,
columnWidths,
dataTableColumnWidthMode,
dataTableDensity,
showColumnComment,
showColumnType,
]);
@@ -3306,35 +3414,53 @@ const DataGrid: React.FC<DataGridProps> = ({
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<string, any> = {};
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<string, any> = {};
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<DataGridProps> = ({
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<DataGridProps> = ({
// 不使用 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<DataGridProps> = ({
},
}),
}));
}, [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<any> => {
const dataIndex = String(col.dataIndex);
@@ -3844,9 +3977,15 @@ const DataGrid: React.FC<DataGridProps> = ({
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<DataGridProps> = ({
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 (
<EditableCell
title={dataIndex}
editable={!!col.editable}
editable={!!col.editable && !rowDeletedForRender}
dataIndex={dataIndex}
record={record}
handleSave={handleCellSave}
focusCell={openCellEditor}
columnType={(columnMetaMap[dataIndex] || columnMetaMapByLowerName[dataIndex.toLowerCase()])?.type}
as="div"
style={VIRTUAL_CELL_WRAPPER_STYLE}
style={virtualCellWrapperStyle}
modifiedColumns={modifiedColumns}
rowKeyStr={rowKeyStr}
deletedRowKeys={deletedRowKeys}
darkMode={darkMode}
>
{originalRenderContent}
</EditableCell>
@@ -3884,7 +4034,7 @@ const DataGrid: React.FC<DataGridProps> = ({
if (enableVirtual) {
return (
<div
style={VIRTUAL_CELL_WRAPPER_STYLE}
style={virtualCellWrapperStyle}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -3898,7 +4048,7 @@ const DataGrid: React.FC<DataGridProps> = ({
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<DataGridProps> = ({
}, [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<DataGridProps> = ({
setAddedRows([]);
setModifiedRows({});
setDeletedRowKeys(new Set());
setModifiedColumns({});
if (onReload) onReload();
} else {
addSqlLog({
@@ -4795,11 +5028,16 @@ const DataGrid: React.FC<DataGridProps> = ({
selectedRowKeys,
onChange: setSelectedRowKeys,
columnWidth: selectionColumnWidth,
renderCell: (_checked: boolean, _record: any, _index: number, originNode: React.ReactNode) => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
{originNode}
</div>
),
}), [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<DataGridProps> = ({
}, [pagination, onPageChange]);
return (
<div className={`${gridId}${cellEditMode ? ' cell-edit-mode' : ''} data-grid-root`} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, minWidth: 0, background: 'transparent' }}>
<div className={`${gridId}${cellEditMode ? ' cell-edit-mode' : ''} data-grid-root`} style={{ '--gonavi-header-min-height': `${headerCellMinHeight}px`, flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, minWidth: 0, background: 'transparent' } as React.CSSProperties}>
{/* Toolbar + Filter Panel */}
<div style={{ margin: `${panelOuterGap}px 0 ${panelOuterGap}px 0`, border: `1px solid ${panelFrameColor}`, borderRadius: `${panelRadius}px`, background: bgFilter, overflow: 'hidden', boxSizing: 'border-box' }}>
<div className="data-grid-toolbar-scroll" data-grid-primary-actions="true" style={{ padding: showFilter ? `${panelPaddingY}px ${panelPaddingX}px ${toolbarBottomPadding}px ${panelPaddingX}px` : `${panelPaddingY}px ${panelPaddingX}px`, border: 'none', borderRadius: 0, background: 'transparent', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'nowrap', minWidth: 0, overflowX: 'auto', overflowY: 'hidden', scrollbarGutter: 'stable', WebkitOverflowScrolling: 'touch', boxSizing: 'border-box' }}>
@@ -5426,7 +5664,11 @@ const DataGrid: React.FC<DataGridProps> = ({
>
{copiedRowsForPaste.length > 0 ? `粘贴行 (${copiedRowsForPaste.length})` : '粘贴行'}
</Button>
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeys.length === 0} onClick={handleDeleteSelected}></Button>
{allSelectedAreDeleted ? (
<Button icon={<UndoOutlined />} disabled={selectedRowKeys.length === 0} onClick={handleUndoDeleteSelected}></Button>
) : (
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeys.length === 0} onClick={handleDeleteSelected}></Button>
)}
{selectedRowKeys.length > 0 && <span style={{ fontSize: '12px', color: '#888' }}> {selectedRowKeys.length}</span>}
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
<Button
@@ -5501,10 +5743,23 @@ const DataGrid: React.FC<DataGridProps> = ({
)}
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
<Button icon={<SaveOutlined />} type="primary" disabled={!hasChanges} onClick={handleCommit}> ({addedRows.length + Object.keys(modifiedRows).length + deletedRowKeys.size})</Button>
{hasChanges && (
<Dropdown menu={{ items: [
{
key: 'preview-sql',
label: '生成预览 SQL',
icon: <ConsoleSqlOutlined />,
onClick: handlePreviewChanges,
}
] }}>
<Button icon={<ConsoleSqlOutlined />}>SQL <DownOutlined /></Button>
</Dropdown>
)}
{hasChanges && (<Button icon={<UndoOutlined />} onClick={() => {
setAddedRows([]);
setModifiedRows({});
setDeletedRowKeys(new Set());
setModifiedColumns({});
}}></Button>)}
</>
)}
@@ -6676,6 +6931,96 @@ const DataGrid: React.FC<DataGridProps> = ({
}}
/>
{/* Preview SQL Modal */}
<Modal
title="变更预览"
open={previewModalOpen}
onCancel={() => setPreviewModalOpen(false)}
width={800}
footer={null}
>
<div style={{ marginBottom: 16 }}>
{previewSqlData.deletes.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontWeight: 'bold', color: '#ff4d4f', marginBottom: 8 }}>
DELETE ({previewSqlData.deletes.length})
</div>
{previewSqlData.deletes.map((sql, i) => (
<div key={`del-${i}`} style={{ position: 'relative', marginBottom: 8 }}>
<pre style={{
background: darkMode ? 'rgba(255, 77, 79, 0.10)' : '#fff2f0',
border: darkMode ? '1px solid rgba(255, 77, 79, 0.25)' : '1px solid #ffccc7',
padding: '8px 40px 8px 12px', borderRadius: 4,
fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
margin: 0,
}}>{sql}</pre>
<Button
size="small" type="text"
icon={<CopyOutlined />}
style={{ position: 'absolute', top: 4, right: 4 }}
onClick={() => { navigator.clipboard.writeText(sql).then(() => message.success('已复制')); }}
/>
</div>
))}
</div>
)}
{previewSqlData.updates.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontWeight: 'bold', color: '#fa8c16', marginBottom: 8 }}>
UPDATE ({previewSqlData.updates.length})
</div>
{previewSqlData.updates.map((sql, i) => (
<div key={`upd-${i}`} style={{ position: 'relative', marginBottom: 8 }}>
<pre style={{
background: darkMode ? 'rgba(250, 140, 22, 0.10)' : '#fff7e6',
border: darkMode ? '1px solid rgba(250, 140, 22, 0.25)' : '1px solid #ffd591',
padding: '8px 40px 8px 12px', borderRadius: 4,
fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
margin: 0,
}}>{sql}</pre>
<Button
size="small" type="text"
icon={<CopyOutlined />}
style={{ position: 'absolute', top: 4, right: 4 }}
onClick={() => { navigator.clipboard.writeText(sql).then(() => message.success('已复制')); }}
/>
</div>
))}
</div>
)}
{previewSqlData.inserts.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontWeight: 'bold', color: '#52c41a', marginBottom: 8 }}>
INSERT ({previewSqlData.inserts.length})
</div>
{previewSqlData.inserts.map((sql, i) => (
<div key={`ins-${i}`} style={{ position: 'relative', marginBottom: 8 }}>
<pre style={{
background: darkMode ? 'rgba(82, 196, 26, 0.10)' : '#f6ffed',
border: darkMode ? '1px solid rgba(82, 196, 26, 0.25)' : '1px solid #b7eb8f',
padding: '8px 40px 8px 12px', borderRadius: 4,
fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
margin: 0,
}}>{sql}</pre>
<Button
size="small" type="text"
icon={<CopyOutlined />}
style={{ position: 'absolute', top: 4, right: 4 }}
onClick={() => { navigator.clipboard.writeText(sql).then(() => message.success('已复制')); }}
/>
</div>
))}
</div>
)}
{previewSqlData.deletes.length === 0 && previewSqlData.updates.length === 0 && previewSqlData.inserts.length === 0 && (
<div style={{ color: darkMode ? '#888' : '#999', textAlign: 'center', padding: 24 }}></div>
)}
</div>
<div style={{ color: darkMode ? '#999' : '#888', fontSize: 12, borderTop: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', paddingTop: 8 }}>
{previewSqlData.deletes.length} DELETE{previewSqlData.updates.length} UPDATE{previewSqlData.inserts.length} INSERT
</div>
</Modal>
{/* Import Preview Modal */}
<ImportPreviewModal
visible={importPreviewVisible}

View File

@@ -1,6 +1,6 @@
const AUTO_FIT_DEFAULT_MIN_WIDTH = 80;
const AUTO_FIT_DEFAULT_MAX_WIDTH = 720;
const AUTO_FIT_DEFAULT_PADDING = 40;
const AUTO_FIT_DEFAULT_PADDING = 20;
const AUTO_FIT_DEFAULT_SAMPLE_LIMIT = 200;
const AUTO_FIT_MAX_PREVIEW_CHARS = 120;

View File

@@ -69,7 +69,7 @@ describe('store appearance persistence', () => {
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 () => {

View File

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

View File

@@ -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', () => {

View File

@@ -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<DataTableDensity, DensityParams> = {
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 = ({

View File

@@ -180,6 +180,8 @@ export function OpenDriverDownloadDirectory(arg1:string):Promise<connection.Quer
export function OpenSQLFile():Promise<connection.QueryResult>;
export function PreviewChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
export function ReadSQLFile(arg1:string):Promise<connection.QueryResult>;

View File

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

View File

@@ -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),

View File

@@ -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)
}

View File

@@ -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 + "`" }

View File

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