feat(datagrid): 增强数据表显示与行级SQL复制

- 新增 DataGrid 竖向分隔线与列宽模式配置并持久化\n- 支持复制 INSERT/UPDATE/DELETE 并按主键或唯一键生成条件\n- 补充外观配置与 SQL 复制相关测试
This commit is contained in:
tianqijiuyun-latiao
2026-04-02 23:41:34 +08:00
parent 255cc14bf6
commit 37b3c78049
9 changed files with 903 additions and 63 deletions

3
.gitignore vendored
View File

@@ -26,4 +26,5 @@ docs/需求追踪/
CLAUDE.md
**/CLAUDE.md
.worktrees
.worktrees
docs

View File

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

View File

@@ -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 无 classAnt 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={{

View File

@@ -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('全部字段');
});
});

View File

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

View 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');
});
});

View File

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

View 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');
});
});

View 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)';
};