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