diff --git a/.gitignore b/.gitignore
index 285e157..0d35fb4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,4 +26,5 @@ docs/需求追踪/
CLAUDE.md
**/CLAUDE.md
-.worktrees
\ No newline at end of file
+.worktrees
+docs
\ No newline at end of file
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index f6c1554..3c6825d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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() {
+
+
数据表显示
+
+
+
+
显示数据表竖向分隔线
+
仅作用于数据表页面 DataGrid,不影响其他表格组件。
+
+
setAppearance({ showDataTableVerticalBorders: checked })}
+ />
+
+
+
数据表列宽模式
+
setAppearance({ dataTableColumnWidthMode: sanitizeDataTableColumnWidthMode(value) })}
+ />
+
+ 标准模式默认列宽 200px;紧凑模式默认列宽 140px。已手动拖拽调整的列宽优先保留。
+
+
+
+
{isMacRuntime ? (
macOS 窗口控制
@@ -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;
-
-
diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx
index d6a31fe..63c8528 100644
--- a/frontend/src/components/DataGrid.tsx
+++ b/frontend/src/components/DataGrid.tsx
@@ -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
;
displayDataRef: React.MutableRefObject;
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;
@@ -785,7 +798,19 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => {
if (!record || !context) return {children}
;
- 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 {children}
;
@@ -806,6 +831,16 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => {
label: '复制为 INSERT',
icon: ,
onClick: () => handleCopyInsert(record),
+ }, {
+ key: 'update',
+ label: '复制为 UPDATE',
+ icon: ,
+ onClick: () => handleCopyUpdate(record),
+ }, {
+ key: 'delete',
+ label: '复制为 DELETE',
+ icon: ,
+ onClick: () => handleCopyDelete(record),
}] : []),
{ key: 'json', label: '复制为 JSON', icon: , onClick: () => handleCopyJson(record) },
{ key: 'csv', label: '复制为 CSV', icon: , onClick: () => handleCopyCsv(record) },
@@ -931,6 +966,13 @@ const DataGrid: React.FC = ({
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 = ({
const [sortInfo, setSortInfo] = useState>([]);
const [columnWidths, setColumnWidths] = useState>({});
const [columnMetaMap, setColumnMetaMap] = useState>({});
+ const [uniqueKeyGroups, setUniqueKeyGroups] = useState([]);
const columnMetaCacheRef = useRef>>({});
const columnMetaSeqRef = useRef(0);
+ const uniqueKeyGroupsCacheRef = useRef>({});
+ const uniqueKeyGroupsSeqRef = useRef(0);
useEffect(() => {
const ext = sortInfoExternal || [];
@@ -1328,10 +1373,12 @@ const DataGrid: React.FC = ({
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 = ({
});
}, [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 = {};
Object.entries(columnMetaMap).forEach(([name, meta]) => {
@@ -1402,6 +1490,17 @@ const DataGrid: React.FC = ({
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 = ({
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 = ({
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 = ({
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 = ({
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 = ({
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 = ({
},
}),
}));
- }, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort, renderColumnTitle]);
+ }, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort, renderColumnTitle, dataTableColumnWidthMode]);
const mergedColumns = useMemo(() => columns.map((col): ColumnType => {
const dataIndex = String(col.dataIndex);
@@ -3554,24 +3666,87 @@ const DataGrid: React.FC = ({
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 = ({
selectedRowKeysRef,
displayDataRef,
handleCopyInsert,
+ handleCopyUpdate,
+ handleCopyDelete,
handleCopyJson,
handleCopyCsv,
handleExportSelected,
@@ -4029,7 +4206,7 @@ const DataGrid: React.FC = ({
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 = ({
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 = ({
>
)}
{supportsCopyInsert && (
- 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
-
+ <>
+ 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
+
+ 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
+
+ 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
+
+ >
)}
{
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('全部字段');
+ });
});
diff --git a/frontend/src/components/dataGridCopyInsert.ts b/frontend/src/components/dataGridCopyInsert.ts
index 3034584..8b8c039 100644
--- a/frontend/src/components/dataGridCopyInsert.ts
+++ b/frontend/src/components/dataGridCopyInsert.ts
@@ -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
;
};
+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, columnName: string): string | undefined => (
+ columnTypesByLowerName[String(columnName || '').toLowerCase()]
+);
+
+const getRecordValue = (
+ record: Record,
+ 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();
+ 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;
+ columnTypesByLowerName: Record;
+ 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();
+ (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();
+ 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);
+};
diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts
new file mode 100644
index 0000000..633130a
--- /dev/null
+++ b/frontend/src/store.test.ts
@@ -0,0 +1,94 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+class MemoryStorage implements Storage {
+ private data = new Map();
+
+ 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');
+ });
+});
diff --git a/frontend/src/store.ts b/frontend/src/store.ts
index 32ca88b..23eff8f 100644
--- a/frontend/src/store.ts
+++ b/frontend/src/store.ts
@@ -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) => void;
setUiScale: (scale: number) => void;
setFontSize: (size: number) => void;
setStartupFullscreen: (enabled: boolean) => void;
@@ -596,12 +614,13 @@ const sanitizeTableHiddenColumns = (value: unknown): Record =>
};
const sanitizeAppearance = (
- appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }> | undefined,
+ appearance: Partial | 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()(
}
)
);
-
-
diff --git a/frontend/src/utils/dataGridDisplay.test.ts b/frontend/src/utils/dataGridDisplay.test.ts
new file mode 100644
index 0000000..0f7e47b
--- /dev/null
+++ b/frontend/src/utils/dataGridDisplay.test.ts
@@ -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');
+ });
+});
diff --git a/frontend/src/utils/dataGridDisplay.ts b/frontend/src/utils/dataGridDisplay.ts
new file mode 100644
index 0000000..32ed056
--- /dev/null
+++ b/frontend/src/utils/dataGridDisplay.ts
@@ -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 | 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)';
+};