mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-11 17:09:45 +08:00
✨ feat(datagrid): 增强数据表显示与行级SQL复制
- 新增 DataGrid 竖向分隔线与列宽模式配置并持久化\n- 支持复制 INSERT/UPDATE/DELETE 并按主键或唯一键生成条件\n- 补充外观配置与 SQL 复制相关测试
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,4 +26,5 @@ docs/需求追踪/
|
||||
|
||||
CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
.worktrees
|
||||
.worktrees
|
||||
docs
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Tooltip } from 'antd';
|
||||
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
|
||||
@@ -11,9 +11,10 @@ import DriverManagerModal from './components/DriverManagerModal';
|
||||
import LogPanel from './components/LogPanel';
|
||||
import AIChatPanel from './components/AIChatPanel';
|
||||
import AISettingsModal from './components/AISettingsModal';
|
||||
import { useStore } from './store';
|
||||
import { DEFAULT_APPEARANCE, useStore } from './store';
|
||||
import { SavedConnection } from './types';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
|
||||
import { DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS, sanitizeDataTableColumnWidthMode } from './utils/dataGridDisplay';
|
||||
import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
|
||||
import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme';
|
||||
import { getConnectionWorkbenchState } from './utils/startupReadiness';
|
||||
@@ -2295,6 +2296,33 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 10, fontWeight: 500 }}>数据表显示</div>
|
||||
<div style={{ display: 'grid', gap: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>显示数据表竖向分隔线</div>
|
||||
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}>仅作用于数据表页面 DataGrid,不影响其他表格组件。</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={appearance.showDataTableVerticalBorders === true}
|
||||
onChange={(checked) => setAppearance({ showDataTableVerticalBorders: checked })}
|
||||
/>
|
||||
</div>
|
||||
<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) })}
|
||||
/>
|
||||
<div style={{ ...utilityMutedTextStyle, marginTop: 8 }}>
|
||||
标准模式默认列宽 200px;紧凑模式默认列宽 140px。已手动拖拽调整的列宽优先保留。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isMacRuntime ? (
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>macOS 窗口控制</div>
|
||||
@@ -2328,7 +2356,7 @@ function App() {
|
||||
onClick={() => {
|
||||
setUiScale(DEFAULT_UI_SCALE);
|
||||
setFontSize(DEFAULT_FONT_SIZE);
|
||||
setAppearance({ enabled: true, opacity: 1.0, blur: 0, useNativeMacWindowControls: false });
|
||||
setAppearance({ ...DEFAULT_APPEARANCE });
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
@@ -2591,5 +2619,3 @@ function App() {
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
|
||||
@@ -23,20 +23,31 @@ import {
|
||||
arrayMove
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns, DBGetIndexes } from '../../wailsjs/go/app/App';
|
||||
import ImportPreviewModal from './ImportPreviewModal';
|
||||
import { useStore } from '../store';
|
||||
import type { ColumnDefinition } from '../types';
|
||||
import type { ColumnDefinition, IndexDefinition } from '../types';
|
||||
import { v4 as generateUuid } from 'uuid';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import {
|
||||
resolveDataTableColumnWidth,
|
||||
resolveDataTableDefaultColumnWidth,
|
||||
resolveDataTableVerticalBorderColor,
|
||||
} from '../utils/dataGridDisplay';
|
||||
import { resolvePaginationPageText, resolvePaginationSummaryText, resolvePaginationTotalForControl } from '../utils/dataGridPagination';
|
||||
import { resolveGridSortInfoFromTableSorter } from '../utils/dataGridSort';
|
||||
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
|
||||
import { buildCopyInsertSQL, normalizeTemporalLiteralText } from './dataGridCopyInsert';
|
||||
import {
|
||||
buildCopyDeleteSQL,
|
||||
buildCopyInsertSQL,
|
||||
buildCopyUpdateSQL,
|
||||
normalizeTemporalLiteralText,
|
||||
resolveUniqueKeyGroupsFromIndexes,
|
||||
} from './dataGridCopyInsert';
|
||||
|
||||
// --- Error Boundary ---
|
||||
interface DataGridErrorBoundaryState {
|
||||
@@ -533,6 +544,8 @@ const DataContext = React.createContext<{
|
||||
selectedRowKeysRef: React.MutableRefObject<React.Key[]>;
|
||||
displayDataRef: React.MutableRefObject<any[]>;
|
||||
handleCopyInsert: (r: any) => void;
|
||||
handleCopyUpdate: (r: any) => void;
|
||||
handleCopyDelete: (r: any) => void;
|
||||
handleCopyJson: (r: any) => void;
|
||||
handleCopyCsv: (r: any) => void;
|
||||
handleExportSelected: (format: string, r: any) => Promise<void>;
|
||||
@@ -785,7 +798,19 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => {
|
||||
|
||||
if (!record || !context) return <tr {...props}>{children}</tr>;
|
||||
|
||||
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, enableRowContextMenu, supportsCopyInsert } = context;
|
||||
const {
|
||||
selectedRowKeysRef,
|
||||
displayDataRef,
|
||||
handleCopyInsert,
|
||||
handleCopyUpdate,
|
||||
handleCopyDelete,
|
||||
handleCopyJson,
|
||||
handleCopyCsv,
|
||||
handleExportSelected,
|
||||
copyToClipboard,
|
||||
enableRowContextMenu,
|
||||
supportsCopyInsert,
|
||||
} = context;
|
||||
|
||||
if (!enableRowContextMenu) {
|
||||
return <tr {...props}>{children}</tr>;
|
||||
@@ -806,6 +831,16 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => {
|
||||
label: '复制为 INSERT',
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => handleCopyInsert(record),
|
||||
}, {
|
||||
key: 'update',
|
||||
label: '复制为 UPDATE',
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => handleCopyUpdate(record),
|
||||
}, {
|
||||
key: 'delete',
|
||||
label: '复制为 DELETE',
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => handleCopyDelete(record),
|
||||
}] : []),
|
||||
{ key: 'json', label: '复制为 JSON', icon: <FileTextOutlined />, onClick: () => handleCopyJson(record) },
|
||||
{ key: 'csv', label: '复制为 CSV', icon: <FileTextOutlined />, onClick: () => handleCopyCsv(record) },
|
||||
@@ -931,6 +966,13 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const darkMode = theme === 'dark';
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const showDataTableVerticalBorders = appearance.showDataTableVerticalBorders === true;
|
||||
const dataTableColumnWidthMode = appearance.dataTableColumnWidthMode;
|
||||
const defaultColumnWidth = resolveDataTableDefaultColumnWidth(dataTableColumnWidthMode);
|
||||
const dataTableVerticalBorderColor = resolveDataTableVerticalBorderColor({
|
||||
darkMode,
|
||||
visible: showDataTableVerticalBorders,
|
||||
});
|
||||
const canModifyData = !readOnly && !!tableName;
|
||||
const showColumnComment = queryOptions?.showColumnComment ?? true;
|
||||
const showColumnType = queryOptions?.showColumnType ?? true;
|
||||
@@ -1312,8 +1354,11 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const [sortInfo, setSortInfo] = useState<Array<{ columnKey: string, order: string, enabled?: boolean }>>([]);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
const [columnMetaMap, setColumnMetaMap] = useState<Record<string, ColumnMeta>>({});
|
||||
const [uniqueKeyGroups, setUniqueKeyGroups] = useState<string[][]>([]);
|
||||
const columnMetaCacheRef = useRef<Record<string, Record<string, ColumnMeta>>>({});
|
||||
const columnMetaSeqRef = useRef(0);
|
||||
const uniqueKeyGroupsCacheRef = useRef<Record<string, string[][]>>({});
|
||||
const uniqueKeyGroupsSeqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const ext = sortInfoExternal || [];
|
||||
@@ -1328,10 +1373,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const normalizedDbName = String(dbName || '').trim();
|
||||
if (!connectionId || !normalizedTableName) {
|
||||
setColumnMetaMap({});
|
||||
setUniqueKeyGroups([]);
|
||||
return;
|
||||
}
|
||||
const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`;
|
||||
setColumnMetaMap(columnMetaCacheRef.current[cacheKey] || {});
|
||||
setUniqueKeyGroups(uniqueKeyGroupsCacheRef.current[cacheKey] || []);
|
||||
}, [connectionId, dbName, tableName]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1382,6 +1429,47 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
});
|
||||
}, [connections, connectionId, dbName, tableName]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedTableName = String(tableName || '').trim();
|
||||
const normalizedDbName = String(dbName || '').trim();
|
||||
if (!connectionId || !normalizedTableName) return;
|
||||
|
||||
const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`;
|
||||
if (uniqueKeyGroupsCacheRef.current[cacheKey]) return;
|
||||
|
||||
const conn = connections.find(c => c.id === connectionId);
|
||||
if (!conn) {
|
||||
setUniqueKeyGroups([]);
|
||||
return;
|
||||
}
|
||||
|
||||
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: "" }
|
||||
};
|
||||
|
||||
const seq = ++uniqueKeyGroupsSeqRef.current;
|
||||
DBGetIndexes(config as any, normalizedDbName, normalizedTableName)
|
||||
.then((res) => {
|
||||
if (seq !== uniqueKeyGroupsSeqRef.current) return;
|
||||
if (!res.success || !Array.isArray(res.data)) {
|
||||
setUniqueKeyGroups([]);
|
||||
return;
|
||||
}
|
||||
const nextGroups = resolveUniqueKeyGroupsFromIndexes(res.data as IndexDefinition[]);
|
||||
uniqueKeyGroupsCacheRef.current[cacheKey] = nextGroups;
|
||||
setUniqueKeyGroups(nextGroups);
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== uniqueKeyGroupsSeqRef.current) return;
|
||||
setUniqueKeyGroups([]);
|
||||
});
|
||||
}, [connections, connectionId, dbName, tableName]);
|
||||
|
||||
const columnMetaMapByLowerName = useMemo(() => {
|
||||
const next: Record<string, ColumnMeta> = {};
|
||||
Object.entries(columnMetaMap).forEach(([name, meta]) => {
|
||||
@@ -1402,6 +1490,17 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return next;
|
||||
}, [columnMetaMapByLowerName]);
|
||||
|
||||
const allTableColumnNames = useMemo(() => {
|
||||
const metaColumns = Object.keys(columnMetaMap);
|
||||
if (metaColumns.length > 0) {
|
||||
return metaColumns;
|
||||
}
|
||||
if (exportScope === 'table') {
|
||||
return columnNames.filter((columnName) => columnName !== GONAVI_ROW_KEY);
|
||||
}
|
||||
return [];
|
||||
}, [columnMetaMap, exportScope, columnNames]);
|
||||
|
||||
const normalizeCommitCellValue = useCallback(
|
||||
(columnName: string, value: any, mode: 'insert' | 'update') => {
|
||||
if (value === undefined) return undefined;
|
||||
@@ -1572,8 +1671,15 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.${gridId} .ant-table-tbody > tr > td,
|
||||
.${gridId} .ant-table-tbody .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 transparent !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 transparent !important; }
|
||||
.${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 > 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,
|
||||
.${gridId} .ant-table-thead > tr > th:last-child {
|
||||
border-inline-end-color: transparent !important;
|
||||
}
|
||||
/* 选择列对齐:header TH 无 class(Ant Design 虚拟模式),需用 :first-child 匹配 */
|
||||
.${gridId} .ant-table-header th:first-child,
|
||||
.${gridId} .ant-table-thead > tr > th:first-child {
|
||||
@@ -2010,7 +2116,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
`, [themeStyles, gridId, tableBodyBottomPadding, darkMode, opacity]);
|
||||
`, [themeStyles, gridId, tableBodyBottomPadding, darkMode, opacity, dataTableVerticalBorderColor]);
|
||||
|
||||
const recalculateTableMetrics = useCallback((targetElement?: HTMLElement | null) => {
|
||||
const target = targetElement || containerRef.current;
|
||||
@@ -2805,7 +2911,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const startX = e.clientX;
|
||||
|
||||
const currentWidth = columnWidths[key] || 200;
|
||||
const currentWidth = resolveDataTableColumnWidth({
|
||||
manualWidth: columnWidths[key],
|
||||
widthMode: dataTableColumnWidthMode,
|
||||
});
|
||||
|
||||
const containerLeft = containerRef.current?.getBoundingClientRect().left ?? 0;
|
||||
|
||||
@@ -2836,7 +2945,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
}, [columnWidths]);
|
||||
}, [columnWidths, dataTableColumnWidthMode]);
|
||||
|
||||
// 2. Drag Move (Global)
|
||||
const handleResizeMove = useCallback((e: MouseEvent) => {
|
||||
@@ -3280,7 +3389,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
dataIndex: key,
|
||||
key: key,
|
||||
// 不使用 ellipsis,避免 Ant Design 的 Tooltip 展开行为
|
||||
width: columnWidths[key] || 200,
|
||||
width: resolveDataTableColumnWidth({
|
||||
manualWidth: columnWidths[key],
|
||||
widthMode: dataTableColumnWidthMode,
|
||||
}),
|
||||
sorter: onSort ? { multiple: displayColumnNames.indexOf(key) + 1 } : false,
|
||||
sortOrder: (sortInfo.find(s => s.columnKey === key && s.enabled !== false)?.order || null) as SortOrder | undefined,
|
||||
editable: canModifyData, // Only editable if table name known and not readonly
|
||||
@@ -3321,7 +3433,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
},
|
||||
}),
|
||||
}));
|
||||
}, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort, renderColumnTitle]);
|
||||
}, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort, renderColumnTitle, dataTableColumnWidthMode]);
|
||||
|
||||
const mergedColumns = useMemo(() => columns.map((col): ColumnType<any> => {
|
||||
const dataIndex = String(col.dataIndex);
|
||||
@@ -3554,24 +3666,87 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return [clickedRecord];
|
||||
}, []);
|
||||
|
||||
const handleCopyInsert = useCallback((record: any) => {
|
||||
const buildCopySqlBatchText = useCallback((mode: 'insert' | 'update' | 'delete', record: any): string | null => {
|
||||
if (!supportsCopyInsert) {
|
||||
void message.warning("当前数据源不支持复制为 INSERT,请使用 JSON/CSV/Markdown 复制。");
|
||||
return;
|
||||
void message.warning("当前数据源不支持复制 SQL,请使用 JSON/CSV/Markdown 复制。");
|
||||
return null;
|
||||
}
|
||||
const records = getTargets(record);
|
||||
// 使用 columnNames 保持表定义的字段顺序,而非 Object.keys() 的不确定顺序
|
||||
const orderedCols = columnNames.filter(c => c !== GONAVI_ROW_KEY);
|
||||
const sqlList = records.map((r: any) => {
|
||||
return buildCopyInsertSQL({
|
||||
if (mode === 'insert') {
|
||||
return records.map((row: any) => buildCopyInsertSQL({
|
||||
dbType,
|
||||
tableName,
|
||||
orderedCols,
|
||||
record: r,
|
||||
record: row,
|
||||
columnTypesByLowerName: columnTypeMapByLowerName,
|
||||
});
|
||||
})).join('\n\n');
|
||||
}
|
||||
|
||||
const sqlResults = records.map((row: any) => (
|
||||
mode === 'update'
|
||||
? buildCopyUpdateSQL({
|
||||
dbType,
|
||||
tableName,
|
||||
orderedCols,
|
||||
record: row,
|
||||
pkColumns,
|
||||
uniqueKeyGroups,
|
||||
allTableColumns: allTableColumnNames,
|
||||
columnTypesByLowerName: columnTypeMapByLowerName,
|
||||
})
|
||||
: buildCopyDeleteSQL({
|
||||
dbType,
|
||||
tableName,
|
||||
orderedCols,
|
||||
record: row,
|
||||
pkColumns,
|
||||
uniqueKeyGroups,
|
||||
allTableColumns: allTableColumnNames,
|
||||
columnTypesByLowerName: columnTypeMapByLowerName,
|
||||
})
|
||||
));
|
||||
const failedResult = sqlResults.find((result) => result.ok === false);
|
||||
if (failedResult && failedResult.ok === false) {
|
||||
void message.warning(failedResult.error);
|
||||
return null;
|
||||
}
|
||||
const sqlTexts: string[] = [];
|
||||
sqlResults.forEach((result) => {
|
||||
if (result.ok) {
|
||||
sqlTexts.push(result.sql);
|
||||
}
|
||||
});
|
||||
copyToClipboard(sqlList.join('\n')); }, [supportsCopyInsert, columnNames, getTargets, copyToClipboard, dbType, tableName, columnTypeMapByLowerName]);
|
||||
return sqlTexts.join('\n\n');
|
||||
}, [
|
||||
supportsCopyInsert,
|
||||
getTargets,
|
||||
columnNames,
|
||||
dbType,
|
||||
tableName,
|
||||
columnTypeMapByLowerName,
|
||||
pkColumns,
|
||||
uniqueKeyGroups,
|
||||
allTableColumnNames,
|
||||
]);
|
||||
|
||||
const handleCopyInsert = useCallback((record: any) => {
|
||||
const batchText = buildCopySqlBatchText('insert', record);
|
||||
if (!batchText) return;
|
||||
copyToClipboard(batchText);
|
||||
}, [buildCopySqlBatchText, copyToClipboard]);
|
||||
|
||||
const handleCopyUpdate = useCallback((record: any) => {
|
||||
const batchText = buildCopySqlBatchText('update', record);
|
||||
if (!batchText) return;
|
||||
copyToClipboard(batchText);
|
||||
}, [buildCopySqlBatchText, copyToClipboard]);
|
||||
|
||||
const handleCopyDelete = useCallback((record: any) => {
|
||||
const batchText = buildCopySqlBatchText('delete', record);
|
||||
if (!batchText) return;
|
||||
copyToClipboard(batchText);
|
||||
}, [buildCopySqlBatchText, copyToClipboard]);
|
||||
|
||||
const handleCopyJson = useCallback((record: any) => {
|
||||
const records = getTargets(record);
|
||||
@@ -4022,6 +4197,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
selectedRowKeysRef,
|
||||
displayDataRef,
|
||||
handleCopyInsert,
|
||||
handleCopyUpdate,
|
||||
handleCopyDelete,
|
||||
handleCopyJson,
|
||||
handleCopyCsv,
|
||||
handleExportSelected,
|
||||
@@ -4029,7 +4206,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
tableName,
|
||||
enableRowContextMenu: false,
|
||||
supportsCopyInsert,
|
||||
}), [handleCopyCsv, handleCopyInsert, handleCopyJson, handleExportSelected, copyToClipboard, tableName, canModifyData, supportsCopyInsert]);
|
||||
}), [handleCopyCsv, handleCopyDelete, handleCopyInsert, handleCopyJson, handleCopyUpdate, handleExportSelected, copyToClipboard, tableName, supportsCopyInsert]);
|
||||
|
||||
const cellContextMenuValue = useMemo(() => ({
|
||||
showMenu: showCellContextMenu,
|
||||
@@ -4044,7 +4221,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const rowPropsFactory = useCallback((record: any) => ({ record } as any), []);
|
||||
|
||||
const totalWidth = columns.reduce((sum: number, col: any) => sum + (Number(col.width) || 200), 0) + selectionColumnWidth;
|
||||
const totalWidth = columns.reduce((sum: number, col: any) => sum + (Number(col.width) || defaultColumnWidth), 0) + selectionColumnWidth;
|
||||
const useContextMenuRow = false;
|
||||
const tableScrollX = useMemo(() => {
|
||||
// rc-table 在 scroll.x 小于容器宽度时会把实际列宽按视口补齐。
|
||||
@@ -5446,21 +5623,53 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
</>
|
||||
)}
|
||||
{supportsCopyInsert && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={() => {
|
||||
if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record);
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}}
|
||||
>
|
||||
复制为 INSERT
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={() => {
|
||||
if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record);
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}}
|
||||
>
|
||||
复制为 INSERT
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={() => {
|
||||
if (cellContextMenu.record) handleCopyUpdate(cellContextMenu.record);
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}}
|
||||
>
|
||||
复制为 UPDATE
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={() => {
|
||||
if (cellContextMenu.record) handleCopyDelete(cellContextMenu.record);
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}}
|
||||
>
|
||||
复制为 DELETE
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildCopyInsertSQL } from './dataGridCopyInsert';
|
||||
import {
|
||||
buildCopyDeleteSQL,
|
||||
buildCopyInsertSQL,
|
||||
buildCopyUpdateSQL,
|
||||
resolveUniqueKeyGroupsFromIndexes,
|
||||
} from './dataGridCopyInsert';
|
||||
|
||||
describe('buildCopyInsertSQL', () => {
|
||||
it('normalizes PostgreSQL timestamp values for copy-as-insert and uses PostgreSQL identifier quoting', () => {
|
||||
@@ -58,4 +63,100 @@ describe('buildCopyInsertSQL', () => {
|
||||
`INSERT INTO public.audit_log (payload) VALUES ('2026-01-21T18:32:26+08:00');`,
|
||||
);
|
||||
});
|
||||
|
||||
it('groups composite unique indexes by name and sequence order', () => {
|
||||
expect(resolveUniqueKeyGroupsFromIndexes([
|
||||
{ name: 'PRIMARY', columnName: 'id', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' },
|
||||
{ name: 'uk_order_code', columnName: 'code', nonUnique: 0, seqInIndex: 2, indexType: 'BTREE' },
|
||||
{ name: 'uk_order_code', columnName: 'tenant_id', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' },
|
||||
{ name: 'idx_note', columnName: 'note', nonUnique: 1, seqInIndex: 1, indexType: 'BTREE' },
|
||||
])).toEqual([
|
||||
['id'],
|
||||
['tenant_id', 'code'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds UPDATE SQL with a primary-key WHERE clause and keeps literal formatting aligned with INSERT', () => {
|
||||
const result = buildCopyUpdateSQL({
|
||||
dbType: 'mysql',
|
||||
tableName: 'orders',
|
||||
orderedCols: ['id', 'note', 'deleted_at'],
|
||||
record: {
|
||||
id: 7,
|
||||
note: "O'Brien",
|
||||
deleted_at: null,
|
||||
},
|
||||
pkColumns: ['id'],
|
||||
columnTypesByLowerName: {
|
||||
deleted_at: 'datetime',
|
||||
},
|
||||
allTableColumns: ['id', 'note', 'deleted_at'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
whereStrategy: 'primary-key',
|
||||
sql: `UPDATE \`orders\` SET \`id\` = '7', \`note\` = 'O''Brien', \`deleted_at\` = NULL WHERE (\`id\` = '7');`,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds DELETE SQL with a composite unique-key WHERE clause when no primary key is available', () => {
|
||||
const result = buildCopyDeleteSQL({
|
||||
dbType: 'postgres',
|
||||
tableName: 'public.audit_log',
|
||||
orderedCols: ['tenant_id', 'code', 'payload'],
|
||||
record: {
|
||||
tenant_id: 'acme',
|
||||
code: 'evt-7',
|
||||
payload: '{"ok":true}',
|
||||
},
|
||||
uniqueKeyGroups: [['tenant_id', 'code']],
|
||||
allTableColumns: ['tenant_id', 'code', 'payload'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
whereStrategy: 'unique-key',
|
||||
sql: `DELETE FROM public.audit_log WHERE (tenant_id = 'acme' AND code = 'evt-7');`,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to all-column matching and uses IS NULL for null values', () => {
|
||||
const result = buildCopyDeleteSQL({
|
||||
dbType: 'sqlserver',
|
||||
tableName: 'dbo.OrderLog',
|
||||
orderedCols: ['id', 'deleted_at', 'flag'],
|
||||
allTableColumns: ['id', 'deleted_at', 'flag'],
|
||||
record: {
|
||||
id: 5,
|
||||
deleted_at: null,
|
||||
flag: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
whereStrategy: 'all-columns',
|
||||
sql: `DELETE FROM [dbo].[OrderLog] WHERE ([id] = '5' AND [deleted_at] IS NULL AND [flag] = 'true');`,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to build UPDATE/DELETE SQL when the result set lacks keys and does not cover all table columns', () => {
|
||||
const result = buildCopyDeleteSQL({
|
||||
dbType: 'mysql',
|
||||
tableName: 'orders',
|
||||
orderedCols: ['note'],
|
||||
allTableColumns: ['id', 'note', 'created_at'],
|
||||
record: {
|
||||
note: 'partial row',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error('expected buildCopyDeleteSQL to fail');
|
||||
}
|
||||
expect(result.error).toContain('主键');
|
||||
expect(result.error).toContain('全部字段');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { IndexDefinition } from '../types';
|
||||
import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
|
||||
|
||||
type BuildCopyInsertSQLParams = {
|
||||
@@ -8,6 +9,22 @@ type BuildCopyInsertSQLParams = {
|
||||
columnTypesByLowerName?: Record<string, string>;
|
||||
};
|
||||
|
||||
type BuildCopyMutationSQLParams = BuildCopyInsertSQLParams & {
|
||||
pkColumns?: string[];
|
||||
uniqueKeyGroups?: string[][];
|
||||
allTableColumns?: string[];
|
||||
};
|
||||
|
||||
type CopySqlWhereStrategy = 'primary-key' | 'unique-key' | 'all-columns';
|
||||
|
||||
export type CopyMutationSQLResult =
|
||||
| { ok: true; sql: string; whereStrategy: CopySqlWhereStrategy }
|
||||
| { ok: false; error: string };
|
||||
|
||||
type CopyMutationWhereClauseResult =
|
||||
| { ok: true; clause: string; whereStrategy: CopySqlWhereStrategy }
|
||||
| { ok: false; error: string };
|
||||
|
||||
const looksLikeDateTimeText = (val: string): boolean => {
|
||||
if (!val) return false;
|
||||
const len = val.length;
|
||||
@@ -104,6 +121,157 @@ export const formatLocalDateTimeLiteral = (value: Date): string => {
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||
};
|
||||
|
||||
const getColumnType = (columnTypesByLowerName: Record<string, string>, columnName: string): string | undefined => (
|
||||
columnTypesByLowerName[String(columnName || '').toLowerCase()]
|
||||
);
|
||||
|
||||
const getRecordValue = (
|
||||
record: Record<string, any>,
|
||||
columnName: string,
|
||||
): { exists: boolean; value: any } => {
|
||||
if (Object.prototype.hasOwnProperty.call(record || {}, columnName)) {
|
||||
return { exists: true, value: record?.[columnName] };
|
||||
}
|
||||
const loweredColumnName = String(columnName || '').toLowerCase();
|
||||
const matchedKey = Object.keys(record || {}).find((key) => key.toLowerCase() === loweredColumnName);
|
||||
if (!matchedKey) {
|
||||
return { exists: false, value: undefined };
|
||||
}
|
||||
return { exists: true, value: record?.[matchedKey] };
|
||||
};
|
||||
|
||||
const normalizeColumnList = (columns: string[] | undefined): string[] => {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
(columns || []).forEach((column) => {
|
||||
const normalized = String(column || '').trim();
|
||||
if (!normalized) return;
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (seen.has(lowered)) return;
|
||||
seen.add(lowered);
|
||||
result.push(normalized);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const toNormalizedLiteralText = (value: any, columnType?: string): string => {
|
||||
if (typeof value === 'string') {
|
||||
return normalizeTemporalLiteralText(value, columnType, true);
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return formatLocalDateTimeLiteral(value);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const formatCopySqlLiteral = (value: any, columnType?: string): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return 'NULL';
|
||||
}
|
||||
return `'${escapeLiteral(toNormalizedLiteralText(value, columnType))}'`;
|
||||
};
|
||||
|
||||
const doesResultCoverAllTableColumns = (orderedCols: string[], allTableColumns: string[]): boolean => {
|
||||
const normalizedOrderedCols = normalizeColumnList(orderedCols);
|
||||
const normalizedAllTableColumns = normalizeColumnList(allTableColumns);
|
||||
if (normalizedOrderedCols.length === 0 || normalizedOrderedCols.length !== normalizedAllTableColumns.length) {
|
||||
return false;
|
||||
}
|
||||
const orderedSet = new Set(normalizedOrderedCols.map((column) => column.toLowerCase()));
|
||||
return normalizedAllTableColumns.every((column) => orderedSet.has(column.toLowerCase()));
|
||||
};
|
||||
|
||||
const buildWhereClauseForColumns = ({
|
||||
dbType,
|
||||
columns,
|
||||
record,
|
||||
columnTypesByLowerName,
|
||||
requireNonNullValues,
|
||||
}: {
|
||||
dbType: string;
|
||||
columns: string[];
|
||||
record: Record<string, any>;
|
||||
columnTypesByLowerName: Record<string, string>;
|
||||
requireNonNullValues: boolean;
|
||||
}): string | null => {
|
||||
const predicates: string[] = [];
|
||||
for (const columnName of columns) {
|
||||
const { exists, value } = getRecordValue(record, columnName);
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
const quotedColumn = quoteIdentPart(dbType, columnName);
|
||||
if (value === null || value === undefined) {
|
||||
if (requireNonNullValues) {
|
||||
return null;
|
||||
}
|
||||
predicates.push(`${quotedColumn} IS NULL`);
|
||||
continue;
|
||||
}
|
||||
predicates.push(`${quotedColumn} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName))}`);
|
||||
}
|
||||
if (predicates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return `(${predicates.join(' AND ')})`;
|
||||
};
|
||||
|
||||
const resolveMutationWhereClause = ({
|
||||
dbType,
|
||||
orderedCols,
|
||||
record,
|
||||
pkColumns = [],
|
||||
uniqueKeyGroups = [],
|
||||
allTableColumns = [],
|
||||
columnTypesByLowerName = {},
|
||||
}: BuildCopyMutationSQLParams): CopyMutationWhereClauseResult => {
|
||||
const normalizedPkColumns = normalizeColumnList(pkColumns);
|
||||
const pkWhereClause = buildWhereClauseForColumns({
|
||||
dbType,
|
||||
columns: normalizedPkColumns,
|
||||
record,
|
||||
columnTypesByLowerName,
|
||||
requireNonNullValues: true,
|
||||
});
|
||||
if (pkWhereClause) {
|
||||
return { ok: true, clause: pkWhereClause, whereStrategy: 'primary-key' };
|
||||
}
|
||||
|
||||
const normalizedUniqueKeyGroups = (uniqueKeyGroups || [])
|
||||
.map((group) => normalizeColumnList(group))
|
||||
.filter((group) => group.length > 0);
|
||||
for (const group of normalizedUniqueKeyGroups) {
|
||||
const uniqueWhereClause = buildWhereClauseForColumns({
|
||||
dbType,
|
||||
columns: group,
|
||||
record,
|
||||
columnTypesByLowerName,
|
||||
requireNonNullValues: true,
|
||||
});
|
||||
if (uniqueWhereClause) {
|
||||
return { ok: true, clause: uniqueWhereClause, whereStrategy: 'unique-key' };
|
||||
}
|
||||
}
|
||||
|
||||
if (doesResultCoverAllTableColumns(orderedCols, allTableColumns)) {
|
||||
const fullRowWhereClause = buildWhereClauseForColumns({
|
||||
dbType,
|
||||
columns: orderedCols,
|
||||
record,
|
||||
columnTypesByLowerName,
|
||||
requireNonNullValues: false,
|
||||
});
|
||||
if (fullRowWhereClause) {
|
||||
return { ok: true, clause: fullRowWhereClause, whereStrategy: 'all-columns' };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: '当前结果集缺少可安全定位行数据的主键/唯一键,且未覆盖表的全部字段,无法生成 WHERE 条件。',
|
||||
};
|
||||
};
|
||||
|
||||
export const buildCopyInsertSQL = ({
|
||||
dbType,
|
||||
tableName,
|
||||
@@ -114,18 +282,136 @@ export const buildCopyInsertSQL = ({
|
||||
const targetTable = quoteQualifiedIdent(dbType, tableName || 'table');
|
||||
const quotedCols = orderedCols.map((col) => quoteIdentPart(dbType, col));
|
||||
const values = orderedCols.map((col) => {
|
||||
const value = record?.[col];
|
||||
if (value === null || value === undefined) return 'NULL';
|
||||
|
||||
const columnType = columnTypesByLowerName[String(col || '').toLowerCase()];
|
||||
const raw =
|
||||
typeof value === 'string'
|
||||
? normalizeTemporalLiteralText(value, columnType, true)
|
||||
: value instanceof Date
|
||||
? formatLocalDateTimeLiteral(value)
|
||||
: String(value);
|
||||
return `'${escapeLiteral(raw)}'`;
|
||||
const { value } = getRecordValue(record, col);
|
||||
return formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, col));
|
||||
});
|
||||
|
||||
return `INSERT INTO ${targetTable} (${quotedCols.join(', ')}) VALUES (${values.join(', ')});`;
|
||||
};
|
||||
|
||||
const buildCopyMutationSQL = (
|
||||
mode: 'update' | 'delete',
|
||||
{
|
||||
dbType,
|
||||
tableName,
|
||||
orderedCols,
|
||||
record,
|
||||
pkColumns = [],
|
||||
uniqueKeyGroups = [],
|
||||
allTableColumns = [],
|
||||
columnTypesByLowerName = {},
|
||||
}: BuildCopyMutationSQLParams,
|
||||
): CopyMutationSQLResult => {
|
||||
const normalizedTableName = String(tableName || '').trim();
|
||||
const normalizedOrderedCols = normalizeColumnList(orderedCols);
|
||||
if (!normalizedTableName) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `当前结果集未关联明确表名,无法生成 ${mode.toUpperCase()} SQL。`,
|
||||
};
|
||||
}
|
||||
if (normalizedOrderedCols.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: '当前结果集没有可复制的字段,无法生成 SQL。',
|
||||
};
|
||||
}
|
||||
|
||||
const whereClause = resolveMutationWhereClause({
|
||||
dbType,
|
||||
orderedCols: normalizedOrderedCols,
|
||||
record,
|
||||
pkColumns,
|
||||
uniqueKeyGroups,
|
||||
allTableColumns,
|
||||
columnTypesByLowerName,
|
||||
});
|
||||
if (whereClause.ok === false) {
|
||||
return { ok: false, error: whereClause.error };
|
||||
}
|
||||
|
||||
const targetTable = quoteQualifiedIdent(dbType, normalizedTableName);
|
||||
if (mode === 'delete') {
|
||||
return {
|
||||
ok: true,
|
||||
sql: `DELETE FROM ${targetTable} WHERE ${whereClause.clause};`,
|
||||
whereStrategy: whereClause.whereStrategy,
|
||||
};
|
||||
}
|
||||
|
||||
const assignments = normalizedOrderedCols.map((columnName) => {
|
||||
const { value } = getRecordValue(record, columnName);
|
||||
return `${quoteIdentPart(dbType, columnName)} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName))}`;
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
sql: `UPDATE ${targetTable} SET ${assignments.join(', ')} WHERE ${whereClause.clause};`,
|
||||
whereStrategy: whereClause.whereStrategy,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildCopyUpdateSQL = (params: BuildCopyMutationSQLParams): CopyMutationSQLResult => (
|
||||
buildCopyMutationSQL('update', params)
|
||||
);
|
||||
|
||||
export const buildCopyDeleteSQL = (params: BuildCopyMutationSQLParams): CopyMutationSQLResult => (
|
||||
buildCopyMutationSQL('delete', params)
|
||||
);
|
||||
|
||||
export const resolveUniqueKeyGroupsFromIndexes = (indexes: IndexDefinition[] | undefined): string[][] => {
|
||||
type IndexBucket = {
|
||||
order: number;
|
||||
columns: Array<{ columnName: string; seqInIndex: number; order: number }>;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, IndexBucket>();
|
||||
(indexes || []).forEach((index, order) => {
|
||||
if (index?.nonUnique !== 0) {
|
||||
return;
|
||||
}
|
||||
const name = String(index?.name || '').trim();
|
||||
const columnName = String(index?.columnName || '').trim();
|
||||
if (!name || !columnName) {
|
||||
return;
|
||||
}
|
||||
if (!buckets.has(name)) {
|
||||
buckets.set(name, { order, columns: [] });
|
||||
}
|
||||
const bucket = buckets.get(name);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
bucket.columns.push({
|
||||
columnName,
|
||||
seqInIndex: Number.isFinite(Number(index?.seqInIndex)) ? Number(index.seqInIndex) : 0,
|
||||
order,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(buckets.values())
|
||||
.sort((left, right) => left.order - right.order)
|
||||
.map((bucket) => {
|
||||
const seen = new Set<string>();
|
||||
return bucket.columns
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftSeq = left.seqInIndex > 0 ? left.seqInIndex : Number.MAX_SAFE_INTEGER;
|
||||
const rightSeq = right.seqInIndex > 0 ? right.seqInIndex : Number.MAX_SAFE_INTEGER;
|
||||
if (leftSeq !== rightSeq) {
|
||||
return leftSeq - rightSeq;
|
||||
}
|
||||
return left.order - right.order;
|
||||
})
|
||||
.map((item) => item.columnName)
|
||||
.filter((columnName) => {
|
||||
const lowered = columnName.toLowerCase();
|
||||
if (seen.has(lowered)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(lowered);
|
||||
return true;
|
||||
});
|
||||
})
|
||||
.filter((group) => group.length > 0);
|
||||
};
|
||||
|
||||
94
frontend/src/store.test.ts
Normal file
94
frontend/src/store.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private data = new Map<string, string>();
|
||||
|
||||
get length(): number {
|
||||
return this.data.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.data.clear();
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.data.has(key) ? this.data.get(key)! : null;
|
||||
}
|
||||
|
||||
key(index: number): string | null {
|
||||
return Array.from(this.data.keys())[index] ?? null;
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.data.delete(key);
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.data.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const importStore = async () => {
|
||||
const store = await import('./store');
|
||||
await store.useStore.persist.rehydrate();
|
||||
return store;
|
||||
};
|
||||
|
||||
describe('store appearance persistence', () => {
|
||||
let storage: MemoryStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new MemoryStorage();
|
||||
vi.stubGlobal('localStorage', storage);
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('fills missing DataGrid appearance settings with defaults during hydration', async () => {
|
||||
storage.setItem('lite-db-storage', JSON.stringify({
|
||||
state: {
|
||||
appearance: {
|
||||
enabled: false,
|
||||
opacity: 0.75,
|
||||
blur: 6,
|
||||
useNativeMacWindowControls: true,
|
||||
},
|
||||
},
|
||||
version: 7,
|
||||
}));
|
||||
|
||||
const { useStore } = await importStore();
|
||||
const appearance = useStore.getState().appearance;
|
||||
|
||||
expect(appearance.enabled).toBe(false);
|
||||
expect(appearance.opacity).toBe(0.75);
|
||||
expect(appearance.blur).toBe(6);
|
||||
expect(appearance.useNativeMacWindowControls).toBe(true);
|
||||
expect(appearance.showDataTableVerticalBorders).toBe(false);
|
||||
expect(appearance.dataTableColumnWidthMode).toBe('standard');
|
||||
});
|
||||
|
||||
it('persists DataGrid appearance settings and restores them after reload', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().setAppearance({
|
||||
showDataTableVerticalBorders: true,
|
||||
dataTableColumnWidthMode: 'compact',
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
|
||||
expect(persisted.state.appearance.showDataTableVerticalBorders).toBe(true);
|
||||
expect(persisted.state.appearance.dataTableColumnWidthMode).toBe('compact');
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await importStore();
|
||||
const appearance = reloaded.useStore.getState().appearance;
|
||||
|
||||
expect(appearance.showDataTableVerticalBorders).toBe(true);
|
||||
expect(appearance.dataTableColumnWidthMode).toBe('compact');
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,26 @@ import {
|
||||
sanitizeShortcutOptions,
|
||||
} from './utils/shortcuts';
|
||||
import { toPersistedGlobalProxy } from './utils/globalProxyDraft';
|
||||
import {
|
||||
DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
|
||||
sanitizeDataGridDisplaySettings,
|
||||
type DataGridDisplaySettings,
|
||||
} from './utils/dataGridDisplay';
|
||||
|
||||
const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0, useNativeMacWindowControls: false };
|
||||
export interface AppearanceSettings extends DataGridDisplaySettings {
|
||||
enabled: boolean;
|
||||
opacity: number;
|
||||
blur: number;
|
||||
useNativeMacWindowControls: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_APPEARANCE: AppearanceSettings = {
|
||||
enabled: true,
|
||||
opacity: 1.0,
|
||||
blur: 0,
|
||||
useNativeMacWindowControls: false,
|
||||
...DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
|
||||
};
|
||||
const DEFAULT_UI_SCALE = 1.0;
|
||||
const MIN_UI_SCALE = 0.8;
|
||||
const MAX_UI_SCALE = 1.25;
|
||||
@@ -26,7 +44,7 @@ const MAX_HOST_ENTRY_LENGTH = 512;
|
||||
const MAX_HOST_ENTRIES = 64;
|
||||
const DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
const MAX_TIMEOUT_SECONDS = 3600;
|
||||
const PERSIST_VERSION = 7;
|
||||
const PERSIST_VERSION = 8;
|
||||
const DEFAULT_CONNECTION_TYPE = 'mysql';
|
||||
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
|
||||
enabled: false,
|
||||
@@ -413,7 +431,7 @@ interface AppState {
|
||||
activeContext: { connectionId: string; dbName: string } | null;
|
||||
savedQueries: SavedQuery[];
|
||||
theme: 'light' | 'dark';
|
||||
appearance: { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean };
|
||||
appearance: AppearanceSettings;
|
||||
uiScale: number;
|
||||
fontSize: number;
|
||||
startupFullscreen: boolean;
|
||||
@@ -472,7 +490,7 @@ interface AppState {
|
||||
deleteQuery: (id: string) => void;
|
||||
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }>) => void;
|
||||
setAppearance: (appearance: Partial<AppearanceSettings>) => void;
|
||||
setUiScale: (scale: number) => void;
|
||||
setFontSize: (size: number) => void;
|
||||
setStartupFullscreen: (enabled: boolean) => void;
|
||||
@@ -596,12 +614,13 @@ const sanitizeTableHiddenColumns = (value: unknown): Record<string, string[]> =>
|
||||
};
|
||||
|
||||
const sanitizeAppearance = (
|
||||
appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }> | undefined,
|
||||
appearance: Partial<AppearanceSettings> | undefined,
|
||||
version: number
|
||||
): { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean } => {
|
||||
): AppearanceSettings => {
|
||||
if (!appearance || typeof appearance !== 'object') {
|
||||
return { ...DEFAULT_APPEARANCE };
|
||||
}
|
||||
const dataGridDisplaySettings = sanitizeDataGridDisplaySettings(appearance);
|
||||
const nextAppearance = {
|
||||
enabled: typeof appearance.enabled === 'boolean' ? appearance.enabled : DEFAULT_APPEARANCE.enabled,
|
||||
opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity,
|
||||
@@ -609,6 +628,8 @@ const sanitizeAppearance = (
|
||||
useNativeMacWindowControls: typeof appearance.useNativeMacWindowControls === 'boolean'
|
||||
? appearance.useNativeMacWindowControls
|
||||
: DEFAULT_APPEARANCE.useNativeMacWindowControls,
|
||||
showDataTableVerticalBorders: dataGridDisplaySettings.showDataTableVerticalBorders,
|
||||
dataTableColumnWidthMode: dataGridDisplaySettings.dataTableColumnWidthMode,
|
||||
};
|
||||
if (version < 2 && isLegacyDefaultAppearance(appearance)) {
|
||||
return { ...DEFAULT_APPEARANCE };
|
||||
@@ -1315,5 +1336,3 @@ export const useStore = create<AppState>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
|
||||
32
frontend/src/utils/dataGridDisplay.test.ts
Normal file
32
frontend/src/utils/dataGridDisplay.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
|
||||
resolveDataTableColumnWidth,
|
||||
resolveDataTableDefaultColumnWidth,
|
||||
resolveDataTableVerticalBorderColor,
|
||||
sanitizeDataGridDisplaySettings,
|
||||
} from './dataGridDisplay';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('resolves standard and compact default column widths', () => {
|
||||
expect(resolveDataTableDefaultColumnWidth('standard')).toBe(200);
|
||||
expect(resolveDataTableDefaultColumnWidth('compact')).toBe(140);
|
||||
});
|
||||
|
||||
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('uses subtle themed vertical border colors and transparent when disabled', () => {
|
||||
expect(resolveDataTableVerticalBorderColor({ darkMode: true, visible: true })).toBe('rgba(255, 255, 255, 0.08)');
|
||||
expect(resolveDataTableVerticalBorderColor({ darkMode: false, visible: true })).toBe('rgba(15, 23, 42, 0.08)');
|
||||
expect(resolveDataTableVerticalBorderColor({ darkMode: false, visible: false })).toBe('transparent');
|
||||
});
|
||||
});
|
||||
72
frontend/src/utils/dataGridDisplay.ts
Normal file
72
frontend/src/utils/dataGridDisplay.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export type DataTableColumnWidthMode = 'standard' | 'compact';
|
||||
|
||||
export interface DataGridDisplaySettings {
|
||||
showDataTableVerticalBorders: boolean;
|
||||
dataTableColumnWidthMode: DataTableColumnWidthMode;
|
||||
}
|
||||
|
||||
export const DEFAULT_DATA_GRID_DISPLAY_SETTINGS: DataGridDisplaySettings = {
|
||||
showDataTableVerticalBorders: false,
|
||||
dataTableColumnWidthMode: 'standard',
|
||||
};
|
||||
|
||||
export const DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS = [
|
||||
{ label: '标准 200px', value: 'standard' as const },
|
||||
{ label: '紧凑 140px', value: 'compact' as const },
|
||||
];
|
||||
|
||||
const STANDARD_DATA_TABLE_COLUMN_WIDTH = 200;
|
||||
const COMPACT_DATA_TABLE_COLUMN_WIDTH = 140;
|
||||
|
||||
export const sanitizeDataTableColumnWidthMode = (value: unknown): DataTableColumnWidthMode => {
|
||||
return value === 'compact' ? 'compact' : 'standard';
|
||||
};
|
||||
|
||||
export const sanitizeDataGridDisplaySettings = (
|
||||
value: Partial<DataGridDisplaySettings> | undefined
|
||||
): DataGridDisplaySettings => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return { ...DEFAULT_DATA_GRID_DISPLAY_SETTINGS };
|
||||
}
|
||||
|
||||
return {
|
||||
showDataTableVerticalBorders: value.showDataTableVerticalBorders === true,
|
||||
dataTableColumnWidthMode: sanitizeDataTableColumnWidthMode(value.dataTableColumnWidthMode),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveDataTableDefaultColumnWidth = (
|
||||
widthMode: DataTableColumnWidthMode | null | undefined
|
||||
): number => {
|
||||
return sanitizeDataTableColumnWidthMode(widthMode) === 'compact'
|
||||
? COMPACT_DATA_TABLE_COLUMN_WIDTH
|
||||
: STANDARD_DATA_TABLE_COLUMN_WIDTH;
|
||||
};
|
||||
|
||||
export const resolveDataTableColumnWidth = ({
|
||||
manualWidth,
|
||||
widthMode,
|
||||
}: {
|
||||
manualWidth: number | null | undefined;
|
||||
widthMode: DataTableColumnWidthMode | null | undefined;
|
||||
}): number => {
|
||||
if (typeof manualWidth === 'number' && Number.isFinite(manualWidth) && manualWidth > 0) {
|
||||
return manualWidth;
|
||||
}
|
||||
|
||||
return resolveDataTableDefaultColumnWidth(widthMode);
|
||||
};
|
||||
|
||||
export const resolveDataTableVerticalBorderColor = ({
|
||||
darkMode,
|
||||
visible,
|
||||
}: {
|
||||
darkMode: boolean;
|
||||
visible: boolean;
|
||||
}): string => {
|
||||
if (!visible) {
|
||||
return 'transparent';
|
||||
}
|
||||
|
||||
return darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.08)';
|
||||
};
|
||||
Reference in New Issue
Block a user