feat(datagrid): 增强列头字段信息展示并优化排序与右键菜单交互

- 新增列头类型/备注常驻显示与悬浮详情展示
- 新增字段信息开关并持久化 showColumnComment/showColumnType 配置
- 排序改为仅箭头区域可触发,排序提示仅显示在排序图标上
- 修复可编辑表中右键菜单重复弹出与透明重影问题
- refs #106
This commit is contained in:
Syngnat
2026-02-14 10:30:01 +08:00
parent 9307ca5e16
commit 7ca2d20c17
2 changed files with 213 additions and 14 deletions

View File

@@ -1,12 +1,13 @@
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented } from 'antd';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover } from 'antd';
import type { SortOrder } from 'antd/es/table/interface';
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges } from '../../wailsjs/go/app/App';
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns } from '../../wailsjs/go/app/App';
import ImportPreviewModal from './ImportPreviewModal';
import { useStore } from '../store';
import type { ColumnDefinition } from '../types';
import { v4 as uuidv4 } from 'uuid';
import 'react-resizable/css/styles.css';
import { buildOrderBySQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
@@ -292,6 +293,7 @@ const DataContext = React.createContext<{
handleExportSelected: (format: string, r: any) => void;
copyToClipboard: (t: string) => void;
tableName?: string;
enableRowContextMenu: boolean;
} | null>(null);
interface Item {
@@ -434,7 +436,11 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => {
if (!record || !context) return <tr {...props}>{children}</tr>;
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard } = context;
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, enableRowContextMenu } = context;
if (!enableRowContextMenu) {
return <tr {...props}>{children}</tr>;
}
const getTargets = () => {
const keys = selectedRowKeysRef.current;
@@ -513,6 +519,11 @@ type GridFilterCondition = FilterCondition & {
type GridViewMode = 'table' | 'json' | 'text';
type ColumnMeta = {
type: string;
comment: string;
};
const DataGrid: React.FC<DataGridProps> = ({
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
onReload, onSort, onPageChange, pagination, sortInfoExternal, showFilter, onToggleFilter, onApplyFilter
@@ -521,10 +532,14 @@ const DataGrid: React.FC<DataGridProps> = ({
const addSqlLog = useStore(state => state.addSqlLog);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const queryOptions = useStore(state => state.queryOptions);
const setQueryOptions = useStore(state => state.setQueryOptions);
const isMacLike = useMemo(() => isMacLikePlatform(), []);
const darkMode = theme === 'dark';
const opacity = normalizeOpacityForPlatform(appearance.opacity);
const canModifyData = !readOnly && !!tableName;
const showColumnComment = queryOptions?.showColumnComment !== false;
const showColumnType = queryOptions?.showColumnType !== false;
const selectionColumnWidth = 46;
// Background Helper
@@ -538,7 +553,7 @@ const DataGrid: React.FC<DataGridProps> = ({
};
const bgContent = getBg('#1d1d1d');
const bgFilter = getBg('#262626');
const bgContextMenu = getBg('#1f1f1f');
const bgContextMenu = darkMode ? '#1f1f1f' : '#ffffff';
// Row Colors with Opacity
const getRowBg = (r: number, g: number, b: number) => `rgba(${r}, ${g}, ${b}, ${opacity})`;
@@ -661,6 +676,9 @@ const DataGrid: React.FC<DataGridProps> = ({
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const [columnMetaMap, setColumnMetaMap] = useState<Record<string, ColumnMeta>>({});
const columnMetaCacheRef = useRef<Record<string, Record<string, ColumnMeta>>>({});
const columnMetaSeqRef = useRef(0);
useEffect(() => {
const nextOrder = sortInfoExternal?.order === 'ascend' || sortInfoExternal?.order === 'descend'
@@ -677,6 +695,129 @@ const DataGrid: React.FC<DataGridProps> = ({
}
}, [sortInfoExternal, sortInfo]);
useEffect(() => {
const normalizedTableName = String(tableName || '').trim();
const normalizedDbName = String(dbName || '').trim();
if (!connectionId || !normalizedTableName) {
setColumnMetaMap({});
return;
}
const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`;
setColumnMetaMap(columnMetaCacheRef.current[cacheKey] || {});
}, [connectionId, dbName, tableName]);
useEffect(() => {
const normalizedTableName = String(tableName || '').trim();
const normalizedDbName = String(dbName || '').trim();
if (!connectionId || !normalizedTableName) return;
const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`;
if (columnMetaCacheRef.current[cacheKey]) return;
const conn = connections.find(c => c.id === connectionId);
if (!conn) {
setColumnMetaMap({});
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 = ++columnMetaSeqRef.current;
DBGetColumns(config as any, normalizedDbName, normalizedTableName)
.then((res) => {
if (seq !== columnMetaSeqRef.current) return;
if (!res.success || !Array.isArray(res.data)) {
setColumnMetaMap({});
return;
}
const nextMap: Record<string, ColumnMeta> = {};
(res.data as ColumnDefinition[]).forEach((column: any) => {
const name = String(column?.name ?? column?.Name ?? '').trim();
if (!name) return;
const type = String(column?.type ?? column?.Type ?? '').trim();
const comment = String(column?.comment ?? column?.Comment ?? '').trim();
nextMap[name] = { type, comment };
});
columnMetaCacheRef.current[cacheKey] = nextMap;
setColumnMetaMap(nextMap);
})
.catch(() => {
if (seq !== columnMetaSeqRef.current) return;
setColumnMetaMap({});
});
}, [connections, connectionId, dbName, tableName]);
const columnMetaMapByLowerName = useMemo(() => {
const next: Record<string, ColumnMeta> = {};
Object.entries(columnMetaMap).forEach(([name, meta]) => {
const lowerName = String(name || '').toLowerCase();
if (!lowerName || next[lowerName]) return;
next[lowerName] = meta;
});
return next;
}, [columnMetaMap]);
const renderColumnTitle = useCallback((name: string): React.ReactNode => {
const normalizedName = String(name || '');
const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()];
const hoverLines: string[] = [];
if (meta?.type) hoverLines.push(`类型:${meta.type}`);
if (meta?.comment) hoverLines.push(`备注:${meta.comment}`);
const titleNode = (
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, lineHeight: 1.2 }}>
<span style={{ whiteSpace: 'nowrap' }}>{normalizedName}</span>
{showColumnType && meta?.type && (
<span
style={{
marginTop: 2,
fontSize: 11,
color: '#8c8c8c',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{meta.type}
</span>
)}
{showColumnComment && meta?.comment && (
<span
style={{
marginTop: 2,
fontSize: 11,
color: '#8c8c8c',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{meta.comment}
</span>
)}
</div>
);
if (hoverLines.length === 0) return titleNode;
return (
<Tooltip
title={<pre style={{ maxHeight: 260, overflow: 'auto', margin: 0, fontSize: 12, whiteSpace: 'pre-wrap' }}>{hoverLines.join('\n')}</pre>}
styles={{ root: { maxWidth: 640 } }}
>
<span style={{ display: 'inline-flex', maxWidth: '100%' }}>{titleNode}</span>
</Tooltip>
);
}, [columnMetaMap, columnMetaMapByLowerName, showColumnComment, showColumnType]);
const closeCellEditor = useCallback(() => {
setCellEditorOpen(false);
setCellEditorMeta(null);
@@ -1592,7 +1733,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const columns = useMemo(() => {
return columnNames.map(key => ({
title: key,
title: renderColumnTitle(key),
dataIndex: key,
key: key,
// 不使用 ellipsis避免 Ant Design 的 Tooltip 展开行为
@@ -1608,9 +1749,29 @@ const DataGrid: React.FC<DataGridProps> = ({
onHeaderCell: (column: any) => ({
width: column.width,
onResizeStart: handleResizeStart(key), // Only need start
onClickCapture: (event: React.MouseEvent<HTMLElement>) => {
if (!onSort) return;
const headerCell = event.currentTarget as HTMLElement;
const upArrow = headerCell.querySelector('.ant-table-column-sorter-up') as HTMLElement | null;
const downArrow = headerCell.querySelector('.ant-table-column-sorter-down') as HTMLElement | null;
const isInArrow = [upArrow, downArrow].some((el) => {
if (!el) return false;
const rect = el.getBoundingClientRect();
return (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
);
});
if (isInArrow) return;
// 仅允许点击上下箭头触发排序,点击字段名或表头其它区域不触发排序。
event.preventDefault();
event.stopPropagation();
},
}),
}));
}, [columnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort]);
}, [columnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort, renderColumnTitle]);
const mergedColumns = useMemo(() => columns.map(col => {
if (!col.editable) return col;
@@ -1620,7 +1781,7 @@ const DataGrid: React.FC<DataGridProps> = ({
record,
editable: col.editable,
dataIndex: col.dataIndex,
title: col.title,
title: String(col.dataIndex),
handleSave: handleCellSave,
focusCell: openCellEditor,
}),
@@ -2037,6 +2198,23 @@ const DataGrid: React.FC<DataGridProps> = ({
{ key: 'md', label: 'Markdown', onClick: () => handleExport('md') },
];
const columnInfoSettingContent = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 168 }}>
<Checkbox
checked={showColumnComment}
onChange={(e) => setQueryOptions({ showColumnComment: e.target.checked })}
>
</Checkbox>
<Checkbox
checked={showColumnType}
onChange={(e) => setQueryOptions({ showColumnType: e.target.checked })}
>
</Checkbox>
</div>
);
const tableComponents = useMemo(() => ({
body: { cell: EditableCell, row: ContextMenuRow },
header: { cell: ResizableTitle }
@@ -2149,6 +2327,15 @@ const DataGrid: React.FC<DataGridProps> = ({
)}
<div style={{ marginLeft: 'auto' }} />
<div style={{ flexShrink: 0 }}>
<Popover
trigger="click"
placement="bottomRight"
content={columnInfoSettingContent}
>
<Button icon={<FileTextOutlined />}></Button>
</Popover>
</div>
<div style={{ flexShrink: 0 }}>
<Segmented
size="small"
@@ -2413,13 +2600,14 @@ const DataGrid: React.FC<DataGridProps> = ({
{viewMode === 'table' ? (
<Form component={false} form={form}>
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName, enableRowContextMenu: !canModifyData }}>
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu, handleBatchFillToSelected }}>
<EditableContext.Provider value={form}>
<Table
components={tableComponents}
dataSource={mergedDisplayData}
columns={mergedColumns}
showSorterTooltip={{ target: 'sorter-icon' }}
size="small"
tableLayout="fixed"
scroll={{ x: tableScrollX, y: tableHeight }}
@@ -2721,6 +2909,9 @@ const DataGrid: React.FC<DataGridProps> = ({
.${gridId} .ant-table-tbody > tr > td { 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-thead > tr > th::before { display: none !important; }
.${gridId} .ant-table-thead > tr > th .ant-table-column-sorters { cursor: default !important; }
.${gridId} .ant-table-thead > tr > th .ant-table-column-sorter,
.${gridId} .ant-table-thead > tr > th .ant-table-column-sorter * { cursor: pointer !important; }
.${gridId} .ant-table-tbody > tr:hover > td { background-color: ${darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)'} !important; }
.${gridId} .ant-table-tbody > tr.ant-table-row-selected > td { background-color: ${darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)'} !important; }
.${gridId} .ant-table-tbody > tr.ant-table-row-selected:hover > td { background-color: ${darkMode ? 'rgba(24, 144, 255, 0.25)' : 'rgba(24, 144, 255, 0.12)'} !important; }

View File

@@ -252,6 +252,12 @@ export interface SqlLog {
affectedRows?: number;
}
export interface QueryOptions {
maxRows: number;
showColumnComment: boolean;
showColumnType: boolean;
}
interface AppState {
connections: SavedConnection[];
tabs: TabData[];
@@ -261,7 +267,7 @@ interface AppState {
theme: 'light' | 'dark';
appearance: { opacity: number; blur: number };
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
queryOptions: { maxRows: number };
queryOptions: QueryOptions;
sqlLogs: SqlLog[];
tableAccessCount: Record<string, number>;
tableSortPreference: Record<string, 'name' | 'frequency'>;
@@ -287,7 +293,7 @@ interface AppState {
setTheme: (theme: 'light' | 'dark') => void;
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
setQueryOptions: (options: Partial<{ maxRows: number }>) => void;
setQueryOptions: (options: Partial<QueryOptions>) => void;
addSqlLog: (log: SqlLog) => void;
clearSqlLogs: () => void;
@@ -326,13 +332,15 @@ const sanitizeSqlFormatOptions = (value: unknown): { keywordCase: 'upper' | 'low
return { keywordCase: raw.keywordCase === 'lower' ? 'lower' : 'upper' };
};
const sanitizeQueryOptions = (value: unknown): { maxRows: number } => {
const sanitizeQueryOptions = (value: unknown): QueryOptions => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const maxRows = Number(raw.maxRows);
const showColumnComment = typeof raw.showColumnComment === 'boolean' ? raw.showColumnComment : true;
const showColumnType = typeof raw.showColumnType === 'boolean' ? raw.showColumnType : true;
if (!Number.isFinite(maxRows) || maxRows <= 0) {
return { maxRows: 5000 };
return { maxRows: 5000, showColumnComment, showColumnType };
}
return { maxRows: Math.min(50000, Math.trunc(maxRows)) };
return { maxRows: Math.min(50000, Math.trunc(maxRows)), showColumnComment, showColumnType };
};
const sanitizeTableAccessCount = (value: unknown): Record<string, number> => {
@@ -383,7 +391,7 @@ export const useStore = create<AppState>()(
theme: 'light',
appearance: { ...DEFAULT_APPEARANCE },
sqlFormatOptions: { keywordCase: 'upper' },
queryOptions: { maxRows: 5000 },
queryOptions: { maxRows: 5000, showColumnComment: true, showColumnType: true },
sqlLogs: [],
tableAccessCount: {},
tableSortPreference: {},