mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-13 07:10:21 +08:00
Merge pull request #453 from TonyJiangWJ/feature/datagrid-enhance
✨ feat(data-grid): 增强数据表编辑与展示体验
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -27,7 +27,7 @@ const storeState = vi.hoisted(() => ({
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
showDataTableVerticalBorders: false,
|
||||
dataTableColumnWidthMode: 'standard',
|
||||
dataTableDensity: 'comfortable',
|
||||
},
|
||||
queryOptions: {
|
||||
showColumnComment: false,
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock('../store', () => ({
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
showDataTableVerticalBorders: false,
|
||||
dataTableColumnWidthMode: 'standard',
|
||||
dataTableDensity: 'comfortable',
|
||||
},
|
||||
queryOptions: {
|
||||
showColumnComment: false,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
135
internal/db/change_preview.go
Normal file
135
internal/db/change_preview.go
Normal 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)
|
||||
}
|
||||
113
internal/db/change_preview_test.go
Normal file
113
internal/db/change_preview_test.go
Normal 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 + "`" }
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user