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})` : '粘贴行'} - + {allSelectedAreDeleted ? ( + + ) : ( + + )} {selectedRowKeys.length > 0 && 已选 {selectedRowKeys.length}}
+ {hasChanges && ( + , + onClick: handlePreviewChanges, + } + ] }}> + + + )} {hasChanges && ()} )} @@ -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}
+
+ ))} +
+ )} + {previewSqlData.updates.length > 0 && ( +
+
+ UPDATE ({previewSqlData.updates.length}) +
+ {previewSqlData.updates.map((sql, i) => ( +
+
{sql}
+
+ ))} +
+ )} + {previewSqlData.inserts.length > 0 && ( +
+
+ INSERT ({previewSqlData.inserts.length}) +
+ {previewSqlData.inserts.map((sql, i) => ( +
+
{sql}
+
+ ))} +
+ )} + {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 {