♻️ refactor(DataGrid): 拆分数据网格视图与交互状态

- 拆分 DataGrid 的筛选、DDL 视图、模态编辑和预览面板状态

- 抽离表头信息、分页栏、视图切换、辅助操作和旧版单元格右键菜单组件

- 优化虚拟单元格渲染判定与横向滚轮意图识别,减少滚动和编辑阶段的无效重绘

- 新增 DataGrid 性能复现页并补齐布局、DDL、列标题与滚动相关测试
This commit is contained in:
Syngnat
2026-05-27 08:43:51 +08:00
parent aa1e8d8a40
commit 0c8c9a9f12
25 changed files with 4780 additions and 2329 deletions

View File

@@ -2,7 +2,12 @@ import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import DataGrid, { buildDataGridCommitChangeSet, GONAVI_ROW_KEY } from './DataGrid';
import DataGrid, {
attachDataGridVirtualEditRenderVersion,
buildDataGridCommitChangeSet,
GONAVI_ROW_KEY,
hasDataGridVirtualEditRenderVersionChanged,
} from './DataGrid';
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
const storeState = vi.hoisted(() => ({
@@ -478,6 +483,20 @@ describe('DataGrid commit change set', () => {
expect(result).toEqual({ ok: false, error: '定位列 EMAIL 的值为空,无法安全提交修改。' });
});
it('marks the active virtual editing row so shouldCellUpdate can reopen inline editors', () => {
const rows = [
{ [GONAVI_ROW_KEY]: 'row-1', id: 1, name: 'alpha' },
{ [GONAVI_ROW_KEY]: 'row-2', id: 2, name: 'beta' },
];
const nextRows = attachDataGridVirtualEditRenderVersion(rows, { rowKey: 'row-1', dataIndex: 'name', title: 'name' });
expect(nextRows[0]).not.toBe(rows[0]);
expect(nextRows[1]).toBe(rows[1]);
expect(hasDataGridVirtualEditRenderVersionChanged(nextRows[0], rows[0])).toBe(true);
expect(hasDataGridVirtualEditRenderVersionChanged(nextRows[1], rows[1])).toBe(false);
});
});
describe('DataGrid DDL interactions', () => {
@@ -633,37 +652,6 @@ describe('DataGrid DDL interactions', () => {
},
);
it('marks v2 table headers as single-line when column type and comment rows are hidden', async () => {
storeState.appearance.uiVersion = 'v2';
storeState.queryOptions.showColumnComment = false;
storeState.queryOptions.showColumnType = false;
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
columnNames={['id']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
const idColumn = testRenderState.latestColumns.find((column) => column.key === 'id');
expect(idColumn).toBeTruthy();
expect(idColumn.onHeaderCell(idColumn).className).toContain('is-single-line-title');
const headerRenderer = create(<>{idColumn.title}</>);
expect(headerRenderer.root.findByProps({ 'data-grid-column-title-single-line': 'true' })).toBeTruthy();
expect(headerRenderer.root.findAllByProps({ className: 'gn-v2-column-title-type' })).toHaveLength(0);
expect(headerRenderer.root.findAllByProps({ className: 'gn-v2-column-title-comment' })).toHaveLength(0);
renderer!.unmount();
});
it('opens the v2 column header context menu from table headers', async () => {
storeState.appearance.uiVersion = 'v2';
storeState.queryOptions.showColumnComment = true;

View File

@@ -346,17 +346,19 @@ describe('DataGrid layout', () => {
it('keeps quick WHERE input clipboard editing isolated from grid shortcuts', () => {
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
const toolbarSource = readFileSync(new URL('./DataGridToolbarFrame.tsx', import.meta.url), 'utf8');
const filterHookSource = readFileSync(new URL('./useDataGridFilters.tsx', import.meta.url), 'utf8');
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
expect(source).toContain('const handleQuickWherePaste = useCallback');
expect(source).toContain("event.clipboardData.getData('text/plain')");
expect(source).toContain('const currentValue = input.value ?? quickWhereDraft;');
expect(source).toContain('event.stopPropagation();');
expect(source).toContain('data-grid-quick-where-input="true"');
expect(source).toContain('{...noAutoCapInputProps}');
expect(source).toContain('onCopy={stopQuickWhereClipboardPropagation}');
expect(source).toContain('onCut={stopQuickWhereClipboardPropagation}');
expect(source).toContain('onPaste={handleQuickWherePaste}');
expect(filterHookSource).toContain('const handleQuickWherePaste = React.useCallback');
expect(filterHookSource).toContain("event.clipboardData.getData('text/plain')");
expect(filterHookSource).toContain('const currentValue = input.value ?? quickWhereDraft;');
expect(filterHookSource).toContain('event.stopPropagation();');
expect(toolbarSource).toContain('data-grid-quick-where-input="true"');
expect(toolbarSource).toContain('{...noAutoCapInputProps}');
expect(toolbarSource).toContain('onCopy={onQuickWhereCopy}');
expect(toolbarSource).toContain('onCut={onQuickWhereCut}');
expect(toolbarSource).toContain('onPaste={onQuickWherePaste}');
expect(source).toContain("['c', 'v', 'x'].includes");
expect(css).toContain('[data-grid-quick-where-input="true"]');
expect(css).toContain('font-size: var(--gn-font-size, 14px) !important;');
@@ -367,12 +369,30 @@ describe('DataGrid layout', () => {
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
expect(source).toContain('virtualHorizontalElementsRef');
expect(source).toContain('resolveDataGridHorizontalWheelDelta({');
expect(source).toContain('const scheduleVirtualHorizontalWheel = useCallback');
expect(source).toContain('pendingTableHorizontalDeltaRef.current += delta;');
expect(source).toContain('tableHorizontalWheelRafRef.current = requestAnimationFrame');
expect(source).toContain('if (externalSyncRafRef.current !== null)');
expect(source).toContain('externalSyncRafRef.current = requestAnimationFrame');
expect(source).toContain('const scheduleSyncExternalScrollFromTargets = useCallback');
expect(source).toContain('tableTargetSyncRafRef.current = requestAnimationFrame');
expect(source).toContain("boundHorizontalTargets = externalScroll ? [] : pickHorizontalScrollTargets(tableContainer);");
expect(source).toContain('const useInlineEditableBodyCell = enableInlineEditableCell && !enableVirtual;');
expect(source).toContain('if (useInlineEditableBodyCell) {');
expect(source).toContain('}, areEditableCellPropsEqual);');
expect(source).toContain('const [virtualEditingCell, setVirtualEditingCell] = useState<VirtualEditingCellState | null>(null);');
expect(source).toContain('const openVirtualInlineEditor = useCallback((record: Item, dataIndex: string, title: React.ReactNode) => {');
expect(source).toContain('if (isVirtualInlineEditingCell && virtualEditable) {');
expect(source).toContain('const DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION = Symbol(\'DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION\');');
expect(source).toContain('const attachDataGridVirtualEditRenderVersion = <T extends Item>(');
expect(source).toContain('hasDataGridVirtualEditRenderVersionChanged(record, prevRecord)');
expect(source).not.toContain('if (enableVirtual && enableInlineEditableCell) {\n return (\n <EditableCell');
expect(source).toContain("content-visibility: ${useAggressiveVirtualPaintHints ? 'auto' : 'visible'};");
expect(source).toContain("contain-intrinsic-size: ${useAggressiveVirtualPaintHints ? '24px 160px' : 'auto'};");
expect(source).toContain("contain: ${useAggressiveVirtualPaintHints ? 'layout paint style' : 'layout style'};");
expect(source).toContain('if (scrollSnapshotRafRef.current !== null) return;');
expect(source).toContain('scrollSnapshotRafRef.current = requestAnimationFrame');
expect(source).toContain("const dataGridBackdropFilter = isMacLike ? 'none' : (opacity < 0.999 ? 'blur(14px)' : 'none');");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { Button, Checkbox, Input } from 'antd';
export interface DataGridColumnInfoPopoverContentProps {
darkMode: boolean;
showColumnComment: boolean;
showColumnType: boolean;
columnSearchText: string;
allOrderedColumnNames: string[];
localHiddenColumns: string[];
enableColumnOrderMemory: boolean;
enableHiddenColumnMemory: boolean;
canResetOrder: boolean;
canResetHidden: boolean;
onShowColumnCommentChange: (checked: boolean) => void;
onShowColumnTypeChange: (checked: boolean) => void;
onToggleAllColumnsVisibility: (visible: boolean) => void;
onColumnSearchTextChange: (value: string) => void;
onToggleColumnVisibility: (columnName: string, visible: boolean) => void;
onEnableColumnOrderMemoryChange: (checked: boolean) => void;
onEnableHiddenColumnMemoryChange: (checked: boolean) => void;
onResetOrder: () => void;
onResetHidden: () => void;
}
const DataGridColumnInfoPopoverContent: React.FC<DataGridColumnInfoPopoverContentProps> = ({
darkMode,
showColumnComment,
showColumnType,
columnSearchText,
allOrderedColumnNames,
localHiddenColumns,
enableColumnOrderMemory,
enableHiddenColumnMemory,
canResetOrder,
canResetHidden,
onShowColumnCommentChange,
onShowColumnTypeChange,
onToggleAllColumnsVisibility,
onColumnSearchTextChange,
onToggleColumnVisibility,
onEnableColumnOrderMemoryChange,
onEnableHiddenColumnMemoryChange,
onResetOrder,
onResetHidden,
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 200, maxWidth: 300 }}>
<div style={{ fontWeight: 600, fontSize: 13, color: darkMode ? '#ddd' : '#666' }}></div>
<Checkbox checked={showColumnComment} onChange={(e) => onShowColumnCommentChange(e.target.checked)}>
</Checkbox>
<Checkbox checked={showColumnType} onChange={(e) => onShowColumnTypeChange(e.target.checked)}>
</Checkbox>
<div style={{ height: 1, backgroundColor: darkMode ? '#424242' : '#f0f0f0', margin: '4px 0' }} />
<div style={{ fontWeight: 600, fontSize: 13, color: darkMode ? '#ddd' : '#666', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span></span>
<div style={{ display: 'flex', gap: 8 }}>
<a style={{ fontSize: 12 }} onClick={() => onToggleAllColumnsVisibility(true)}></a>
<a style={{ fontSize: 12 }} onClick={() => onToggleAllColumnsVisibility(false)}></a>
</div>
</div>
<Input
placeholder="搜索列名..."
size="small"
value={columnSearchText}
onChange={(e) => onColumnSearchTextChange(e.target.value)}
allowClear
/>
<div className="custom-scrollbar" style={{ maxHeight: 240, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
{allOrderedColumnNames
.filter((col) => !columnSearchText || col.toLowerCase().includes(columnSearchText.toLowerCase()))
.map((col) => (
<Checkbox
key={col}
checked={!localHiddenColumns.includes(col)}
onChange={(e) => onToggleColumnVisibility(col, e.target.checked)}
style={{ marginLeft: 0 }}
>
{col}
</Checkbox>
))}
</div>
<div style={{ height: 1, backgroundColor: darkMode ? '#424242' : '#f0f0f0', margin: '4px 0' }} />
<Checkbox checked={enableColumnOrderMemory} onChange={(e) => onEnableColumnOrderMemoryChange(e.target.checked)}>
</Checkbox>
<Checkbox checked={enableHiddenColumnMemory} onChange={(e) => onEnableHiddenColumnMemoryChange(e.target.checked)}>
</Checkbox>
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<Button size="small" danger style={{ flex: 1 }} disabled={!canResetOrder} onClick={onResetOrder}>
</Button>
<Button size="small" danger style={{ flex: 1 }} disabled={!canResetHidden} onClick={onResetHidden}>
</Button>
</div>
</div>
);
export default DataGridColumnInfoPopoverContent;

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import DataGridColumnTitle from './DataGridColumnTitle';
vi.mock('antd', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
describe('DataGridColumnTitle', () => {
it('marks v2 table headers as single-line when column type and comment rows are hidden', () => {
const markup = renderToStaticMarkup(
<DataGridColumnTitle
columnName="id"
showColumnType={false}
showColumnComment={false}
metaFontSize={11}
columnMetaHintColor="#999"
columnMetaTooltipColor="#fff"
darkMode={false}
/>,
);
expect(markup).toContain('data-grid-column-title-single-line="true"');
expect(markup).not.toContain('gn-v2-column-title-type');
expect(markup).not.toContain('gn-v2-column-title-comment');
});
it('renders column type and comment rows when enabled', () => {
const markup = renderToStaticMarkup(
<DataGridColumnTitle
columnName="id"
columnMeta={{ type: 'bigint', comment: '主键 ID' }}
showColumnType
showColumnComment
metaFontSize={11}
columnMetaHintColor="#999"
columnMetaTooltipColor="#fff"
darkMode={false}
/>,
);
expect(markup).toContain('class="gn-v2-column-title"');
expect(markup).toContain('class="gn-v2-column-title-type"');
expect(markup).toContain('bigint');
expect(markup).toContain('class="gn-v2-column-title-comment"');
expect(markup).toContain('主键 ID');
expect(markup).toContain('flex-direction:column');
expect(markup).toContain('align-items:flex-start');
});
it('renders foreign-key jump affordance when reference target exists', () => {
const markup = renderToStaticMarkup(
<DataGridColumnTitle
columnName="customer_id"
foreignKeyTarget={{ refTableName: 'customers', refColumnName: 'id' }}
showColumnType={false}
showColumnComment={false}
metaFontSize={11}
columnMetaHintColor="#999"
columnMetaTooltipColor="#fff"
darkMode={false}
/>,
);
expect(markup).toContain('data-grid-fk-jump="true"');
expect(markup).toContain('data-ref-table-name="customers"');
});
});

View File

@@ -0,0 +1,168 @@
import React from 'react';
import { Tooltip } from 'antd';
import { LinkOutlined } from '@ant-design/icons';
export interface DataGridColumnTitleProps {
columnName: string;
columnMeta?: {
type?: string;
comment?: string;
} | null;
foreignKeyTarget?: {
refTableName?: string;
refColumnName?: string;
} | null;
showColumnType: boolean;
showColumnComment: boolean;
metaFontSize: number;
columnMetaHintColor: string;
columnMetaTooltipColor: string;
darkMode: boolean;
onOpenForeignKey?: () => void;
}
const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
columnName,
columnMeta,
foreignKeyTarget,
showColumnType,
showColumnComment,
metaFontSize,
columnMetaHintColor,
columnMetaTooltipColor,
darkMode,
onOpenForeignKey,
}) => {
const normalizedName = String(columnName || '');
const columnType = String(columnMeta?.type || '').trim();
const columnComment = String(columnMeta?.comment || '').trim();
const refTableName = String(foreignKeyTarget?.refTableName || '').trim();
const refColumnName = String(foreignKeyTarget?.refColumnName || '').trim();
const shouldShowColumnType = showColumnType && columnType.length > 0;
const shouldShowColumnComment = showColumnComment && columnComment.length > 0;
const isSingleLineColumnTitle = !shouldShowColumnType && !shouldShowColumnComment;
const hoverLines: string[] = [];
if (columnType) hoverLines.push(`类型:${columnType}`);
if (columnComment) hoverLines.push(`备注:${columnComment}`);
if (refTableName) {
const refColumnText = refColumnName ? `.${refColumnName}` : '';
hoverLines.push(`外键:${refTableName}${refColumnText}`);
}
const fieldLabel = refTableName ? (
<button
type="button"
data-grid-fk-jump="true"
data-column-name={normalizedName}
data-ref-table-name={refTableName}
title={`跳转到外键表:${refTableName}`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenForeignKey?.();
}}
onPointerDown={(event) => event.stopPropagation()}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
minWidth: 0,
maxWidth: '100%',
padding: 0,
border: 0,
background: 'transparent',
color: 'inherit',
font: 'inherit',
lineHeight: 'inherit',
cursor: 'pointer',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
{normalizedName}
</span>
<LinkOutlined style={{ fontSize: metaFontSize + 1, color: columnMetaHintColor, flex: 'none' }} />
</button>
) : (
<span style={{ whiteSpace: 'nowrap' }}>{normalizedName}</span>
);
const titleNode = (
<div
className={isSingleLineColumnTitle ? 'gn-v2-column-title is-single-line' : 'gn-v2-column-title'}
data-grid-column-title-single-line={isSingleLineColumnTitle ? 'true' : undefined}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'center',
minWidth: 0,
maxWidth: '100%',
lineHeight: 1.2,
}}
>
{fieldLabel}
{shouldShowColumnType && (
<span
className="gn-v2-column-title-type"
style={{
marginTop: 2,
fontSize: metaFontSize,
color: columnMetaHintColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{columnType}
</span>
)}
{shouldShowColumnComment && (
<span
className="gn-v2-column-title-comment"
style={{
marginTop: 2,
fontSize: metaFontSize,
color: columnMetaHintColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{columnComment}
</span>
)}
</div>
);
if (hoverLines.length === 0) {
return titleNode;
}
return (
<Tooltip
title={(
<pre
style={{
maxHeight: 260,
overflow: 'auto',
margin: 0,
fontSize: 12,
whiteSpace: 'pre-wrap',
color: darkMode ? columnMetaTooltipColor : '#fff',
}}
>
{hoverLines.join('\n')}
</pre>
)}
styles={{ root: { maxWidth: 640 } }}
{...(!darkMode ? { color: 'rgba(0, 0, 0, 0.82)' } : {})}
>
<span style={{ display: 'inline-flex', maxWidth: '100%' }}>{titleNode}</span>
</Tooltip>
);
};
export default DataGridColumnTitle;

View File

@@ -0,0 +1,183 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { CopyOutlined, EditOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
interface CellContextMenuState {
visible: boolean;
x: number;
y: number;
record: Record<string, any> | null;
dataIndex: string;
}
interface DataGridLegacyCellContextMenuProps {
visible: boolean;
darkMode: boolean;
bgContextMenu: string;
cellContextMenu: CellContextMenuState;
canModifyData: boolean;
selectedRowKeysLength: number;
copiedCellPatchAvailable: boolean;
supportsCopyInsert: boolean;
onClose: () => void;
onCopyFieldName: () => void;
onSetNull: () => void;
onEditRow: () => void;
onFillToSelected: () => void;
onPasteCopiedColumns: () => void;
onCopyInsert: () => void;
onCopyUpdate: () => void;
onCopyDelete: () => void;
onCopyJson: () => void;
onCopyCsv: () => void;
onCopyMarkdown: () => void;
onExportCsv: () => void;
onExportXlsx: () => void;
onExportJson: () => void;
onExportHtml: () => void;
}
const baseItemStyle: React.CSSProperties = {
padding: '8px 12px',
cursor: 'pointer',
transition: 'background 0.2s',
};
const separatorStyle = (darkMode: boolean): React.CSSProperties => ({
height: 1,
background: darkMode ? '#303030' : '#f0f0f0',
margin: '4px 0',
});
const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps> = ({
visible,
darkMode,
bgContextMenu,
cellContextMenu,
canModifyData,
selectedRowKeysLength,
copiedCellPatchAvailable,
supportsCopyInsert,
onClose,
onCopyFieldName,
onSetNull,
onEditRow,
onFillToSelected,
onPasteCopiedColumns,
onCopyInsert,
onCopyUpdate,
onCopyDelete,
onCopyJson,
onCopyCsv,
onCopyMarkdown,
onExportCsv,
onExportXlsx,
onExportJson,
onExportHtml,
}) => {
if (!visible) {
return null;
}
const hoverBg = darkMode ? '#303030' : '#f5f5f5';
const canFillRows = selectedRowKeysLength > 0;
const makeHoverHandlers = (enabled = true) => ({
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
if (enabled) e.currentTarget.style.background = hoverBg;
},
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
e.currentTarget.style.background = 'transparent';
},
});
const closeAfter = (callback: () => void) => () => {
callback();
onClose();
};
return createPortal(
<div
data-grid-legacy-cell-context-menu="true"
style={{
position: 'fixed',
left: cellContextMenu.x,
top: cellContextMenu.y,
zIndex: 10000,
background: bgContextMenu,
border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9',
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
minWidth: 160,
maxHeight: `calc(100vh - ${cellContextMenu.y}px - 8px)`,
overflowY: 'auto',
color: darkMode ? '#fff' : 'rgba(0, 0, 0, 0.88)',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={onCopyFieldName}>
<CopyOutlined style={{ marginRight: 8 }} />
</div>
<div style={separatorStyle(darkMode)} />
{canModifyData && (
<>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={onSetNull}>
NULL
</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={onEditRow}>
<EditOutlined style={{ marginRight: 8 }} />
</div>
<div
style={{
...baseItemStyle,
cursor: canFillRows ? 'pointer' : 'not-allowed',
opacity: canFillRows ? 1 : 0.5,
}}
{...makeHoverHandlers(canFillRows)}
onClick={() => {
if (canFillRows) onFillToSelected();
}}
>
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
({selectedRowKeysLength})
</div>
<div
style={{
...baseItemStyle,
cursor: copiedCellPatchAvailable ? 'pointer' : 'not-allowed',
opacity: copiedCellPatchAvailable ? 1 : 0.5,
}}
{...makeHoverHandlers(copiedCellPatchAvailable)}
onClick={() => {
if (copiedCellPatchAvailable) onPasteCopiedColumns();
}}
>
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
</div>
<div style={separatorStyle(darkMode)} />
</>
)}
{supportsCopyInsert && (
<>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyInsert)}> INSERT</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyUpdate)}> UPDATE</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyDelete)}> DELETE</div>
</>
)}
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyJson)}> JSON</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyCsv)}> CSV</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyMarkdown)}> Markdown</div>
<div style={separatorStyle(darkMode)} />
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportCsv)}> CSV</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportXlsx)}> Excel</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportJson)}> JSON</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportHtml)}> HTML</div>
</div>,
document.body,
);
};
export default DataGridLegacyCellContextMenu;

View File

@@ -0,0 +1,313 @@
import React from 'react';
import { Button, Checkbox, DatePicker, Form, Input, Modal, TimePicker } from 'antd';
import dayjs from 'dayjs';
import { CopyOutlined } from '@ant-design/icons';
import Editor from './MonacoEditor';
import {
TEMPORAL_FORMATS,
getTemporalPickerType,
type TemporalPickerType,
} from './dataGridTemporal';
type ColumnMeta = {
type: string;
comment: string;
};
export interface DataGridRowEditorField {
columnName: string;
sample: string;
placeholder?: string;
isJson: boolean;
useTextArea: boolean;
pickerType?: TemporalPickerType;
isTemporalValue: boolean;
isWritable: boolean;
}
export interface DataGridModalsProps {
tableName?: string;
darkMode: boolean;
displayColumnNames: string[];
rowEditorOpen: boolean;
rowEditorRowKey: string;
rowEditorForm: any;
rowEditorFields: DataGridRowEditorField[];
onCloseRowEditor: () => void;
onApplyRowEditor: () => void;
onOpenRowEditorFieldEditor: (columnName: string) => void;
cellEditorOpen: boolean;
cellEditorMeta: { record: Record<string, unknown>; dataIndex: string; title: string } | null;
cellEditorIsJson: boolean;
cellEditorValue: string;
onCloseCellEditor: () => void;
onFormatJsonInEditor: () => void;
onSaveCellEditor: () => void;
onCellEditorValueChange: (value: string) => void;
batchEditModalOpen: boolean;
selectedCellsSize: number;
batchEditSetNull: boolean;
batchEditValue: string;
onCloseBatchEditModal: () => void;
onApplyBatchFill: () => void;
onBatchEditSetNullChange: (checked: boolean) => void;
onBatchEditValueChange: (value: string) => void;
jsonEditorOpen: boolean;
jsonEditorValue: string;
onCloseJsonEditor: () => void;
onFormatJsonEditor: () => void;
onApplyJsonEditor: () => void;
onJsonEditorValueChange: (value: string) => void;
ddlModalOpen: boolean;
ddlLoading: boolean;
ddlText: string;
onCloseDdlModal: () => void;
onCopyDdl: () => void;
}
const DataGridModals: React.FC<DataGridModalsProps> = ({
tableName,
darkMode,
rowEditorOpen,
rowEditorRowKey,
rowEditorForm,
rowEditorFields,
onCloseRowEditor,
onApplyRowEditor,
onOpenRowEditorFieldEditor,
cellEditorOpen,
cellEditorMeta,
cellEditorIsJson,
cellEditorValue,
onCloseCellEditor,
onFormatJsonInEditor,
onSaveCellEditor,
onCellEditorValueChange,
batchEditModalOpen,
selectedCellsSize,
batchEditSetNull,
batchEditValue,
onCloseBatchEditModal,
onApplyBatchFill,
onBatchEditSetNullChange,
onBatchEditValueChange,
jsonEditorOpen,
jsonEditorValue,
onCloseJsonEditor,
onFormatJsonEditor,
onApplyJsonEditor,
onJsonEditorValueChange,
ddlModalOpen,
ddlLoading,
ddlText,
onCloseDdlModal,
onCopyDdl,
}) => (
<>
<Modal
title="编辑行"
open={rowEditorOpen}
onCancel={onCloseRowEditor}
width={980}
destroyOnHidden
maskClosable={false}
footer={[
<Button key="cancel" onClick={onCloseRowEditor}></Button>,
<Button key="ok" type="primary" onClick={onApplyRowEditor}></Button>,
]}
>
<div style={{ marginBottom: 8, color: '#888', fontSize: 12, display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<span>{tableName ? `${tableName}` : ''}</span>
<span>{rowEditorRowKey ? `rowKey: ${rowEditorRowKey}` : ''}</span>
</div>
<Form form={rowEditorForm} layout="vertical">
<div className="custom-scrollbar" style={{ maxHeight: '62vh', overflow: 'auto', paddingRight: 8 }}>
{rowEditorFields.map((field) => (
<Form.Item key={field.columnName} label={field.columnName} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Form.Item name={field.columnName} noStyle>
{field.isTemporalValue && field.pickerType ? (
field.pickerType === 'time' ? (
<TimePicker
style={{ flex: 1, width: '100%' }}
format={TEMPORAL_FORMATS[field.pickerType]}
placeholder={field.placeholder}
needConfirm={false}
disabled={!field.isWritable}
/>
) : field.pickerType === 'datetime' ? (
<DatePicker
style={{ flex: 1, width: '100%' }}
showTime
format={TEMPORAL_FORMATS[field.pickerType]}
placeholder={field.placeholder}
needConfirm
disabled={!field.isWritable}
/>
) : (
<DatePicker
style={{ flex: 1, width: '100%' }}
format={TEMPORAL_FORMATS[field.pickerType]}
picker={field.pickerType as any}
placeholder={field.placeholder}
needConfirm={false}
disabled={!field.isWritable}
/>
)
) : field.useTextArea ? (
<Input.TextArea
style={{ flex: 1 }}
autoSize={{ minRows: field.isJson ? 4 : 1, maxRows: 10 }}
placeholder={field.placeholder}
disabled={!field.isWritable}
/>
) : (
<Input style={{ flex: 1 }} placeholder={field.placeholder} disabled={!field.isWritable} />
)}
</Form.Item>
<Button
size="small"
onClick={() => onOpenRowEditorFieldEditor(field.columnName)}
title="弹窗编辑"
disabled={!field.isWritable}
>
...
</Button>
</div>
</Form.Item>
))}
</div>
</Form>
</Modal>
<Modal
title={cellEditorMeta ? `编辑单元格:${cellEditorMeta.title}` : '编辑单元格'}
open={cellEditorOpen}
onCancel={onCloseCellEditor}
destroyOnHidden
width={960}
maskClosable={false}
footer={[
<Button key="format" onClick={onFormatJsonInEditor} disabled={!cellEditorIsJson}> JSON</Button>,
<Button key="cancel" onClick={onCloseCellEditor}></Button>,
<Button key="ok" type="primary" onClick={onSaveCellEditor}></Button>,
]}
>
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
{cellEditorMeta ? `${tableName || ''}${tableName ? '.' : ''}${cellEditorMeta.dataIndex}` : ''}
</div>
{cellEditorOpen && (
<Editor
height="56vh"
language={cellEditorIsJson ? 'json' : 'plaintext'}
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={cellEditorValue}
onChange={(value) => onCellEditorValueChange(value || '')}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'on',
fontSize: 14,
tabSize: 2,
automaticLayout: true,
}}
/>
)}
</Modal>
<Modal
title={`批量填充 (${selectedCellsSize} 个单元格)`}
open={batchEditModalOpen}
onCancel={onCloseBatchEditModal}
onOk={onApplyBatchFill}
width={500}
>
<div style={{ marginBottom: 16 }}>
<Checkbox checked={batchEditSetNull} onChange={(event) => onBatchEditSetNullChange(event.target.checked)}>
NULL
</Checkbox>
</div>
{!batchEditSetNull && (
<Input.TextArea
value={batchEditValue}
onChange={(event) => onBatchEditValueChange(event.target.value)}
placeholder="输入要填充的值"
autoSize={{ minRows: 3, maxRows: 10 }}
autoFocus
/>
)}
</Modal>
<Modal
title="编辑 JSON 结果集"
open={jsonEditorOpen}
onCancel={onCloseJsonEditor}
destroyOnHidden
width={980}
maskClosable={false}
footer={[
<Button key="format" onClick={onFormatJsonEditor}> JSON</Button>,
<Button key="cancel" onClick={onCloseJsonEditor}></Button>,
<Button key="ok" type="primary" onClick={onApplyJsonEditor}></Button>,
]}
>
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
JSON
</div>
{jsonEditorOpen && (
<Editor
height="56vh"
language="json"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={jsonEditorValue}
onChange={(value) => onJsonEditorValueChange(value || '')}
options={{
readOnly: false,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'off',
fontSize: 12,
tabSize: 2,
automaticLayout: true,
}}
/>
)}
</Modal>
<Modal
title={tableName ? `DDL - ${tableName}` : 'DDL'}
open={ddlModalOpen}
onCancel={onCloseDdlModal}
destroyOnHidden
width={960}
footer={[
<Button key="copy" icon={<CopyOutlined />} onClick={onCopyDdl} disabled={!ddlText.trim()}>
DDL
</Button>,
<Button key="close" type="primary" onClick={onCloseDdlModal}>
</Button>,
]}
>
{ddlModalOpen && (
<Editor
height="56vh"
language="sql"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={ddlLoading ? '正在加载 DDL...' : ddlText}
options={{
readOnly: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'off',
fontSize: 12,
tabSize: 2,
automaticLayout: true,
}}
/>
)}
</Modal>
</>
);
export default DataGridModals;

View File

@@ -0,0 +1,79 @@
import React from 'react';
import { Button, Input, Tooltip } from 'antd';
import { LeftOutlined, RightOutlined, SearchOutlined } from '@ant-design/icons';
export interface DataGridPageFindProps {
isV2Ui: boolean;
darkMode: boolean;
inputProps?: Record<string, unknown>;
pageFindText: string;
normalizedPageFindText: string;
hasMatches: boolean;
activePageFindPosition: number;
matchCount: number;
occurrenceCount: number;
matchedCellCount: number;
onPageFindTextChange: (value: string) => void;
onNavigatePrevious: () => void;
onNavigateNext: () => void;
}
const DataGridPageFind: React.FC<DataGridPageFindProps> = ({
isV2Ui,
darkMode,
inputProps,
pageFindText,
normalizedPageFindText,
hasMatches,
activePageFindPosition,
matchCount,
occurrenceCount,
matchedCellCount,
onPageFindTextChange,
onNavigatePrevious,
onNavigateNext,
}) => (
<Tooltip title="仅查找当前页已加载数据,不改变 WHERE 条件">
<div
data-grid-page-find="true"
className={isV2Ui ? 'gn-v2-data-grid-page-find' : undefined}
style={isV2Ui ? undefined : { display: 'flex', alignItems: 'center', gap: 6 }}
>
<Input
{...inputProps}
allowClear
size="small"
prefix={<SearchOutlined />}
placeholder="当前页查找..."
value={pageFindText}
onChange={(event) => onPageFindTextChange(event.target.value)}
style={isV2Ui ? undefined : { width: 220 }}
/>
<Button
data-grid-page-find-prev="true"
size="small"
icon={<LeftOutlined />}
disabled={!hasMatches}
onClick={onNavigatePrevious}
>
{isV2Ui ? null : '上一个'}
</Button>
<Button
data-grid-page-find-next="true"
size="small"
icon={<RightOutlined />}
disabled={!hasMatches}
onClick={onNavigateNext}
>
{isV2Ui ? null : '下一个'}
</Button>
{normalizedPageFindText && (
<span aria-live="polite" style={isV2Ui ? undefined : { fontSize: 12, color: darkMode ? '#999' : '#666', whiteSpace: 'nowrap' }}>
{hasMatches ? `${activePageFindPosition} / ${matchCount} · ` : ''} {occurrenceCount} / {matchedCellCount}
</span>
)}
</div>
</Tooltip>
);
export default DataGridPageFind;

View File

@@ -0,0 +1,126 @@
import React from 'react';
import { Button, Pagination, Select } from 'antd';
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
interface DataGridPaginationState {
current: number;
pageSize: number;
total: number;
totalKnown?: boolean;
totalApprox?: boolean;
approximateTotal?: number;
totalCountLoading?: boolean;
totalCountCancelled?: boolean;
}
export interface DataGridPaginationBarProps {
isV2Ui: boolean;
pagination?: DataGridPaginationState;
paginationV2SummaryText: string;
paginationSummaryText: string;
paginationPageText: string;
paginationControlTotal: number;
paginationTotalPages: number;
paginationPageSizeOptions: string[];
onPageChange?: (page: number, size: number) => void;
onPageSizeChange: (value: string) => void;
onV2PageStep: (direction: 'previous' | 'next') => void;
}
const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
isV2Ui,
pagination,
paginationV2SummaryText,
paginationSummaryText,
paginationPageText,
paginationControlTotal,
paginationTotalPages,
paginationPageSizeOptions,
onPageChange,
onPageSizeChange,
onV2PageStep,
}) => {
if (!pagination) {
return null;
}
return (
<div
className={`${isV2Ui ? 'gn-v2-data-grid-pagination-wrap ' : ''}data-grid-pagination-wrap`}
style={isV2Ui ? undefined : { padding: '12px 0 0', borderTop: 'none', display: 'flex', justifyContent: 'flex-end' }}
>
{isV2Ui ? (
<div className="data-grid-pagination-shell" data-grid-v2-pagination="true">
<div className="data-grid-pagination-summary" aria-live="polite">
<span className="data-grid-pagination-summary-value">{paginationV2SummaryText}</span>
</div>
<Button
data-grid-v2-pagination-prev="true"
size="small"
icon={<LeftOutlined />}
disabled={!onPageChange || pagination.current <= 1}
onClick={() => onV2PageStep('previous')}
/>
<div className="data-grid-pagination-page-chip" data-grid-v2-page-chip="true">
<strong>{pagination.current}</strong>
<span>/</span>
<span>{paginationTotalPages}</span>
</div>
<Button
data-grid-v2-pagination-next="true"
size="small"
icon={<RightOutlined />}
disabled={!onPageChange || pagination.current >= paginationTotalPages}
onClick={() => onV2PageStep('next')}
/>
<Select
size="small"
popupMatchSelectWidth={false}
value={String(pagination.pageSize)}
onChange={onPageSizeChange}
options={paginationPageSizeOptions.map((value) => ({ value, label: `${value} /页` }))}
className="data-grid-pagination-size-select"
aria-label="每页条数"
/>
</div>
) : (
<div className="data-grid-pagination-shell">
<div className="data-grid-pagination-summary" aria-live="polite">
<span className="data-grid-pagination-kicker"></span>
<span className="data-grid-pagination-summary-value">{paginationSummaryText}</span>
</div>
<div className="data-grid-pagination-page-chip">{paginationPageText}</div>
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={paginationControlTotal}
showSizeChanger={false}
onChange={onPageChange}
showTitle={false}
size="small"
itemRender={(_page, type, originalElement) => {
if (type === 'prev') {
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><LeftOutlined /></span>;
}
if (type === 'next') {
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><RightOutlined /></span>;
}
return originalElement;
}}
/>
<Select
size="small"
popupMatchSelectWidth={false}
value={String(pagination.pageSize)}
onChange={onPageSizeChange}
options={paginationPageSizeOptions.map((value) => ({ value, label: `${value} 条 / 页` }))}
className="data-grid-pagination-size-select"
aria-label="每页条数"
/>
</div>
)}
</div>
);
};
export default DataGridPaginationBar;

View File

@@ -0,0 +1,131 @@
import React from 'react';
import { Button } from 'antd';
import Editor from './MonacoEditor';
type ColumnMeta = {
type?: string;
};
interface DataGridPreviewPanelProps {
visible: boolean;
isTableSurfaceActive: boolean;
darkMode: boolean;
focusedCellInfo: { dataIndex: string } | null;
dataPanelIsJson: boolean;
focusedCellWritable: boolean;
dataPanelValue: string;
columnMetaMap: Record<string, ColumnMeta>;
columnMetaMapByLowerName: Record<string, ColumnMeta>;
onFormatJson: () => void;
onSave: () => void;
onValueChange: (value: string) => void;
onDirtyChange: (dirty: boolean) => void;
isDirtyComparedToOriginal: (value: string) => boolean;
}
const DataGridPreviewPanel: React.FC<DataGridPreviewPanelProps> = ({
visible,
isTableSurfaceActive,
darkMode,
focusedCellInfo,
dataPanelIsJson,
focusedCellWritable,
dataPanelValue,
columnMetaMap,
columnMetaMapByLowerName,
onFormatJson,
onSave,
onValueChange,
onDirtyChange,
isDirtyComparedToOriginal,
}) => {
if (!visible || !isTableSurfaceActive) {
return null;
}
const meta = focusedCellInfo
? (columnMetaMap[focusedCellInfo.dataIndex] || columnMetaMapByLowerName[focusedCellInfo.dataIndex.toLowerCase()])
: undefined;
return (
<div
data-grid-preview-panel="true"
style={{
height: 200,
borderTop: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.12)',
display: 'flex',
flexDirection: 'column',
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(255,255,255,0.6)',
flexShrink: 0,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '4px 10px',
fontSize: 12,
borderBottom: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(0,0,0,0.06)',
flexShrink: 0,
}}
>
<span style={{ color: darkMode ? '#aaa' : '#666', fontWeight: 500 }}>
{focusedCellInfo ? focusedCellInfo.dataIndex : '点击单元格查看数据'}
</span>
{meta?.type ? <span style={{ color: '#888', fontSize: 11 }}>({meta.type})</span> : null}
<div style={{ flex: 1 }} />
{dataPanelIsJson && (
<Button size="small" onClick={onFormatJson}> JSON</Button>
)}
{focusedCellWritable && (
<Button size="small" type="primary" onClick={onSave}></Button>
)}
</div>
<div style={{ flex: 1, minHeight: 0 }}>
{focusedCellInfo ? (
<Editor
height="100%"
language={dataPanelIsJson ? 'json' : 'plaintext'}
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={dataPanelValue}
onChange={(val) => {
const newVal = val || '';
onValueChange(newVal);
onDirtyChange(isDirtyComparedToOriginal(newVal));
}}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'on',
fontSize: 13,
tabSize: 2,
automaticLayout: true,
readOnly: !focusedCellWritable,
lineNumbers: 'off',
glyphMargin: false,
folding: false,
lineDecorationsWidth: 4,
padding: { top: 6, bottom: 6 },
}}
/>
) : (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#999',
fontSize: 13,
}}
>
</div>
)}
</div>
</div>
);
};
export default DataGridPreviewPanel;

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { Button } from 'antd';
import Editor from './MonacoEditor';
interface DataGridJsonViewProps {
darkMode: boolean;
rowCount: number;
canModifyData: boolean;
jsonViewText: string;
onOpenJsonEditor: () => void;
}
export const DataGridJsonView: React.FC<DataGridJsonViewProps> = ({
darkMode,
rowCount,
canModifyData,
jsonViewText,
onOpenJsonEditor,
}) => (
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 10px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}>
{rowCount === 0 ? '当前结果集无数据' : `当前结果集 ${rowCount} 条记录`}
</span>
{canModifyData && (
<Button size="small" type="primary" onClick={onOpenJsonEditor} disabled={rowCount === 0}>
JSON
</Button>
)}
</div>
<div style={{ flex: 1, minHeight: 0, padding: '8px 10px 10px 10px' }}>
<Editor
height="100%"
defaultLanguage="json"
language="json"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={jsonViewText}
options={{
readOnly: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'off',
fontSize: 12,
tabSize: 2,
automaticLayout: true,
}}
/>
</div>
</div>
);
interface DataGridTextViewProps {
darkMode: boolean;
rowCount: number;
textRecordIndex: number;
canModifyData: boolean;
currentTextRow: Record<string, any> | null;
displayOutputColumnNames: string[];
onPrev: () => void;
onNext: () => void;
onEditCurrent: () => void;
formatTextViewValue: (value: any, columnName?: string) => string;
}
export const DataGridTextView: React.FC<DataGridTextViewProps> = ({
darkMode,
rowCount,
textRecordIndex,
canModifyData,
currentTextRow,
displayOutputColumnNames,
onPrev,
onNext,
onEditCurrent,
formatTextViewValue,
}) => (
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 12px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8 }}>
<Button size="small" onClick={onPrev} disabled={rowCount === 0 || textRecordIndex <= 0}>
</Button>
<Button size="small" onClick={onNext} disabled={rowCount === 0 || textRecordIndex >= rowCount - 1}>
</Button>
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}>
{rowCount === 0 ? '当前结果集无数据' : `记录 ${textRecordIndex + 1} / ${rowCount}`}
</span>
{canModifyData && (
<Button size="small" type="primary" onClick={onEditCurrent} disabled={rowCount === 0}>
</Button>
)}
</div>
<div className="custom-scrollbar" style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '8px 12px' }}>
{currentTextRow ? displayOutputColumnNames.map((col) => (
<div key={col} style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 10, padding: '6px 0', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(0,0,0,0.06)', alignItems: 'start' }}>
<div style={{ fontWeight: 600, color: darkMode ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.88)', wordBreak: 'break-all' }}>
{col} :
</div>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(0,0,0,0.88)' }}>
{formatTextViewValue(currentTextRow[col], col)}
</div>
</div>
)) : (
<div style={{ fontSize: 12, color: darkMode ? '#999' : '#666', paddingTop: 4 }}>
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { Segmented } from 'antd';
type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er';
export interface DataGridResultViewSwitcherProps {
isV2Ui: boolean;
darkMode: boolean;
viewMode: GridViewMode;
onViewModeChange: (nextMode: GridViewMode) => void;
}
const DataGridResultViewSwitcher: React.FC<DataGridResultViewSwitcherProps> = ({
isV2Ui,
darkMode,
viewMode,
onViewModeChange,
}) => (
<div
data-grid-view-switcher="true"
className={isV2Ui ? 'gn-v2-data-grid-result-switcher' : undefined}
style={isV2Ui ? undefined : { display: 'flex', alignItems: 'center', gap: 8 }}
>
<span style={isV2Ui ? undefined : { fontSize: 12, color: darkMode ? '#999' : '#666' }}></span>
<Segmented
size="small"
value={viewMode === 'json' || viewMode === 'text' ? viewMode : 'table'}
options={[
{ label: '表格', value: 'table' },
{ label: 'JSON', value: 'json' },
{ label: '文本', value: 'text' },
]}
onChange={(value) => onViewModeChange(String(value) as GridViewMode)}
/>
</div>
);
export default DataGridResultViewSwitcher;

View File

@@ -0,0 +1,152 @@
import React from 'react';
import { Button, Popover } from 'antd';
import {
ConsoleSqlOutlined,
EditOutlined,
FileTextOutlined,
LinkOutlined,
TableOutlined,
} from '@ant-design/icons';
type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er';
export interface DataGridSecondaryActionsProps {
isV2Ui: boolean;
canViewDdl: boolean;
viewMode: GridViewMode;
ddlLoading: boolean;
showColumnComment: boolean;
showColumnType: boolean;
mergedDisplayCount: number;
pendingChangeCount: number;
resultViewSwitcher: React.ReactNode;
columnInfoSettingContent: React.ReactNode;
pageFindContent: React.ReactNode;
paginationContent: React.ReactNode;
onViewModeChange: (nextMode: GridViewMode) => void;
dataPanelOpen: boolean;
isTableSurfaceActive: boolean;
onToggleDataPanel: () => void;
onOpenTableDdl: () => void;
}
const DataGridSecondaryActions: React.FC<DataGridSecondaryActionsProps> = ({
isV2Ui,
canViewDdl,
viewMode,
ddlLoading,
showColumnComment,
showColumnType,
mergedDisplayCount,
pendingChangeCount,
resultViewSwitcher,
columnInfoSettingContent,
pageFindContent,
paginationContent,
onViewModeChange,
dataPanelOpen,
isTableSurfaceActive,
onToggleDataPanel,
onOpenTableDdl,
}) => {
if (isV2Ui) {
const viewTabItems: Array<{ key: GridViewMode; label: string; icon: React.ReactNode; disabled?: boolean }> = [
{ key: 'table', label: '数据预览', icon: <TableOutlined /> },
{ key: 'fields', label: '字段信息', icon: <FileTextOutlined /> },
{ key: 'ddl', label: '查看 DDL', icon: <ConsoleSqlOutlined />, disabled: !canViewDdl },
{ key: 'er', label: 'ER 图', icon: <LinkOutlined /> },
];
return (
<div data-grid-secondary-actions="true" className="gn-v2-data-grid-statusbar">
<div className="gn-v2-data-grid-view-tabs">
{viewTabItems.map((item) => (
<Button
data-grid-ddl-action={item.key === 'ddl' && canViewDdl ? 'true' : undefined}
key={item.key}
size="small"
type={viewMode === item.key || (item.key === 'table' && (viewMode === 'json' || viewMode === 'text')) ? 'primary' : 'text'}
icon={item.icon}
disabled={item.disabled}
loading={item.key === 'ddl' && ddlLoading}
onClick={() => {
if (item.key === 'table') {
onViewModeChange('table');
return;
}
onViewModeChange(item.key);
}}
>
{item.label}
</Button>
))}
</div>
<div className="gn-v2-toolbar-divider" />
{resultViewSwitcher}
<Popover trigger="click" placement="topRight" content={columnInfoSettingContent}>
<Button
data-grid-column-display-action="true"
size="small"
type={showColumnComment || showColumnType ? 'primary' : 'text'}
icon={<FileTextOutlined />}
>
</Button>
</Popover>
<div className="gn-v2-data-grid-status-center">
<span className="gn-v2-data-grid-live">live</span>
<span>{mergedDisplayCount} </span>
<span> {pendingChangeCount}</span>
</div>
{pageFindContent}
<div className="gn-v2-data-grid-pagination-spacer" aria-hidden="true" />
{paginationContent}
</div>
);
}
return (
<>
<div
data-grid-secondary-actions="true"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
flexWrap: 'wrap',
padding: '4px 0 0',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Button
icon={<EditOutlined />}
type={dataPanelOpen ? 'primary' : 'default'}
disabled={!isTableSurfaceActive}
onClick={onToggleDataPanel}
>
</Button>
<Popover trigger="click" placement="bottomRight" content={columnInfoSettingContent}>
<Button data-grid-column-display-action="true" icon={<FileTextOutlined />}></Button>
</Popover>
{canViewDdl && (
<Button
data-grid-ddl-action="true"
icon={<FileTextOutlined />}
loading={ddlLoading}
onClick={onOpenTableDdl}
>
DDL
</Button>
)}
{pageFindContent}
</div>
{resultViewSwitcher}
</div>
{paginationContent}
</>
);
};
export default DataGridSecondaryActions;

View File

@@ -0,0 +1,731 @@
import React from 'react';
import { AutoComplete, Button, Checkbox, Dropdown, Input, Select, Tooltip } from 'antd';
import type { MenuProps } from 'antd';
import {
ClearOutlined,
CloseOutlined,
ConsoleSqlOutlined,
CopyOutlined,
DeleteOutlined,
DownOutlined,
EditOutlined,
ExportOutlined,
FilterOutlined,
ImportOutlined,
PlusOutlined,
ReloadOutlined,
RobotOutlined,
SaveOutlined,
TableOutlined,
UndoOutlined,
VerticalAlignBottomOutlined,
} from '@ant-design/icons';
type GridFilterCondition = {
id: number;
enabled?: boolean;
logic?: string;
column: string;
op: string;
value: string;
value2?: string;
};
type GridSortInfo = {
columnKey: string;
order: string;
enabled?: boolean;
};
export interface DataGridToolbarFrameProps {
isV2Ui: boolean;
tableName?: string;
dbName?: string;
loading: boolean;
darkMode: boolean;
bgFilter: string;
panelFrameColor: string;
panelRadius: number;
panelOuterGap: number;
panelPaddingY: number;
panelPaddingX: number;
toolbarBottomPadding: number;
filterTopPadding: number;
selectionAccentHex: string;
toolbarDividerColor: string;
showFilter?: boolean;
filterPanelRef?: React.RefObject<HTMLDivElement>;
onReload?: () => void;
onToggleFilter?: () => void;
canModifyData: boolean;
selectedRowKeysLength: number;
copiedRowsForPasteLength: number;
allSelectedAreDeleted: boolean;
cellEditMode: boolean;
selectedCellsSize: number;
copiedCellPatchColumnCount: number;
hasChanges: boolean;
pendingChangeCount: number;
canImport: boolean;
canExport: boolean;
isQueryResultExport: boolean;
canCopyQueryResult: boolean;
prefersManualTotalCount: boolean;
aiShortcutLabel: string;
legacyAiButtonStyle?: React.CSSProperties;
paginationTotalCountLoading?: boolean;
filterConditions: GridFilterCondition[];
sortInfo: GridSortInfo[];
displayColumnNames: string[];
quickWhereDraft: string;
quickWhereCondition?: string;
quickWhereSuggestionsOpen: boolean;
quickWhereSuggestionOptions: Array<{ value: string; label?: React.ReactNode; insertText?: string }>;
gridFieldSelectOptions: Array<{ value: string; label: string; title: string }>;
filterLogicOptions: Array<{ value: string; label: string }>;
filterOpOptions: Array<{ value: string; label: string }>;
renderGridFieldSelectOption: (option: { label?: React.ReactNode; value?: unknown; title?: unknown }) => React.ReactNode;
noAutoCapInputProps: Record<string, unknown>;
filterFieldSelectStyle: React.CSSProperties;
filterFieldPopupWidth: number;
exportMenu: MenuProps['items'];
queryResultCopyMenu: MenuProps['items'];
dbType: string;
onResetPendingChanges: () => void;
onRefresh: () => void;
onToggleFilterClick: () => void;
onAddRow: () => void;
onCopySelectedRowsForPaste: () => void;
onPasteCopiedRowsAsNew: () => void;
onUndoDeleteSelected: () => void;
onDeleteSelected: () => void;
onToggleCellEditMode: () => void;
onCopySelectedCellsToClipboard: () => void;
onCopySelectedColumnsFromRow: () => void;
onOpenBatchEditModal: () => void;
onPasteCopiedColumnsToSelectedRows: () => void;
onCommit: () => void;
onPreviewChanges: () => void;
onImport: () => void;
onCopyQueryResultCsv: () => void;
onRequestAiInsight: () => void;
onToggleTotalCount: () => void;
onQuickWhereDraftChange: (value: string) => void;
onQuickWhereSuggestionsOpenChange: (open: boolean) => void;
onQuickWhereKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onQuickWhereSelect: (value: string, option: unknown) => void;
onQuickWhereCopy: (event: React.ClipboardEvent<HTMLInputElement>) => void;
onQuickWhereCut: (event: React.ClipboardEvent<HTMLInputElement>) => void;
onQuickWherePaste: (event: React.ClipboardEvent<HTMLInputElement>) => void;
onApplyQuickWhere: () => void;
onClearQuickWhere: () => void;
updateFilter: (id: number, field: keyof GridFilterCondition, value: string | boolean) => void;
removeFilter: (id: number) => void;
addFilter: () => void;
isListOp: (op: string) => boolean;
isBetweenOp: (op: string) => boolean;
isNoValueOp: (op: string) => boolean;
enableSortControls: boolean;
onApplySortInfo: (next: GridSortInfo[]) => void;
onApplyFilters: () => void;
onEnableAllFilters: () => void;
onDisableAllFilters: () => void;
onClearFiltersAndSorts: () => void;
}
const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
isV2Ui,
tableName,
dbName,
loading,
darkMode,
bgFilter,
panelFrameColor,
panelRadius,
panelOuterGap,
panelPaddingY,
panelPaddingX,
toolbarBottomPadding,
filterTopPadding,
selectionAccentHex,
toolbarDividerColor,
showFilter,
filterPanelRef,
onReload,
onToggleFilter,
canModifyData,
selectedRowKeysLength,
copiedRowsForPasteLength,
allSelectedAreDeleted,
cellEditMode,
selectedCellsSize,
copiedCellPatchColumnCount,
hasChanges,
pendingChangeCount,
canImport,
canExport,
isQueryResultExport,
canCopyQueryResult,
prefersManualTotalCount,
aiShortcutLabel,
legacyAiButtonStyle,
paginationTotalCountLoading,
filterConditions,
sortInfo,
displayColumnNames,
quickWhereDraft,
quickWhereCondition,
quickWhereSuggestionsOpen,
quickWhereSuggestionOptions,
gridFieldSelectOptions,
filterLogicOptions,
filterOpOptions,
renderGridFieldSelectOption,
noAutoCapInputProps,
filterFieldSelectStyle,
filterFieldPopupWidth,
exportMenu,
queryResultCopyMenu,
dbType,
onResetPendingChanges,
onRefresh,
onToggleFilterClick,
onAddRow,
onCopySelectedRowsForPaste,
onPasteCopiedRowsAsNew,
onUndoDeleteSelected,
onDeleteSelected,
onToggleCellEditMode,
onCopySelectedCellsToClipboard,
onCopySelectedColumnsFromRow,
onOpenBatchEditModal,
onPasteCopiedColumnsToSelectedRows,
onCommit,
onPreviewChanges,
onImport,
onCopyQueryResultCsv,
onRequestAiInsight,
onToggleTotalCount,
onQuickWhereDraftChange,
onQuickWhereSuggestionsOpenChange,
onQuickWhereKeyDown,
onQuickWhereSelect,
onQuickWhereCopy,
onQuickWhereCut,
onQuickWherePaste,
onApplyQuickWhere,
onClearQuickWhere,
updateFilter,
removeFilter,
addFilter,
isListOp,
isBetweenOp,
isNoValueOp,
enableSortControls,
onApplySortInfo,
onApplyFilters,
onEnableAllFilters,
onDisableAllFilters,
onClearFiltersAndSorts,
}) => {
const renderToolbarDivider = () => (
<div
className={isV2Ui ? 'gn-v2-toolbar-divider' : undefined}
style={isV2Ui ? undefined : { width: 1, height: 18, background: toolbarDividerColor, margin: '0 2px', flexShrink: 0 }}
aria-hidden="true"
/>
);
const quickWherePlaceholder = dbType === 'mongodb'
? '输入 MongoDB JSON 查询对象,例如 {"status":"A"}'
: '输入 WHERE 后面的条件,例如 status = 1 AND name LIKE \'A%\'';
return (
<div
className={isV2Ui ? 'gn-v2-data-grid-toolbar-frame' : undefined}
style={{
margin: `${panelOuterGap}px 0 ${panelOuterGap}px 0`,
border: `1px solid ${panelFrameColor}`,
borderRadius: `${panelRadius}px`,
background: bgFilter,
overflow: 'hidden',
boxSizing: 'border-box',
}}
>
<div
className="data-grid-toolbar-scroll"
data-grid-primary-actions="true"
style={{
padding: showFilter ? `${panelPaddingY}px ${panelPaddingX}px ${toolbarBottomPadding}px ${panelPaddingX}px` : `${panelPaddingY}px ${panelPaddingX}px`,
border: 'none',
borderRadius: 0,
background: 'transparent',
display: 'flex',
gap: 8,
alignItems: 'center',
flexWrap: 'nowrap',
minWidth: 0,
overflowX: 'auto',
overflowY: 'hidden',
scrollbarGutter: 'stable',
WebkitOverflowScrolling: 'touch',
boxSizing: 'border-box',
}}
>
{isV2Ui && (
<>
<div className="gn-v2-data-grid-toolbar-title">
<TableOutlined className="gn-v2-data-grid-icon" />
<strong title={tableName || '查询结果'}>{tableName || '查询结果'}</strong>
{dbName && <small title={dbName}>· {dbName}</small>}
</div>
{renderToolbarDivider()}
</>
)}
{onReload && (
<Button icon={<ReloadOutlined />} disabled={loading} onClick={onRefresh}>
</Button>
)}
{onToggleFilter && (
<>
{renderToolbarDivider()}
<Button icon={<FilterOutlined />} type={showFilter ? 'primary' : 'default'} onClick={onToggleFilterClick}>
</Button>
</>
)}
{canModifyData && (
<>
{renderToolbarDivider()}
<Button icon={<PlusOutlined />} onClick={onAddRow}></Button>
<Button
data-grid-copy-row-action="true"
icon={<CopyOutlined />}
disabled={selectedRowKeysLength === 0}
onClick={onCopySelectedRowsForPaste}
>
</Button>
<Button
data-grid-paste-row-action="true"
icon={<VerticalAlignBottomOutlined />}
disabled={copiedRowsForPasteLength === 0}
onClick={onPasteCopiedRowsAsNew}
>
{copiedRowsForPasteLength > 0 ? `粘贴行 (${copiedRowsForPasteLength})` : '粘贴行'}
</Button>
{allSelectedAreDeleted ? (
<Button icon={<UndoOutlined />} disabled={selectedRowKeysLength === 0} onClick={onUndoDeleteSelected}></Button>
) : (
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeysLength === 0} onClick={onDeleteSelected}></Button>
)}
{selectedRowKeysLength > 0 && <span style={{ fontSize: '12px', color: '#888' }}> {selectedRowKeysLength}</span>}
{renderToolbarDivider()}
<Button
data-grid-cell-editor-action="true"
icon={<EditOutlined />}
type={cellEditMode ? 'primary' : 'default'}
onClick={onToggleCellEditMode}
>
</Button>
{cellEditMode && selectedCellsSize > 0 && (
<>
<Button icon={<CopyOutlined />} onClick={onCopySelectedCellsToClipboard}>
({selectedCellsSize})
</Button>
<Button icon={<CopyOutlined />} onClick={onCopySelectedColumnsFromRow}>
({selectedCellsSize})
</Button>
<Button type="primary" onClick={onOpenBatchEditModal}>
({selectedCellsSize})
</Button>
</>
)}
{cellEditMode && copiedCellPatchColumnCount > 0 && (
<>
<Button
icon={<VerticalAlignBottomOutlined />}
disabled={selectedRowKeysLength === 0}
onClick={onPasteCopiedColumnsToSelectedRows}
>
({selectedRowKeysLength})
</Button>
<span style={{ fontSize: '12px', color: '#888' }}>
{copiedCellPatchColumnCount}
</span>
</>
)}
{renderToolbarDivider()}
<Button
className={isV2Ui ? 'gn-v2-commit-button' : undefined}
icon={<SaveOutlined />}
type="primary"
disabled={!hasChanges}
onClick={onCommit}
>
{isV2Ui ? (
<>
<span></span>
<span className="gn-v2-toolbar-kbd">{pendingChangeCount}</span>
</>
) : `提交事务 (${pendingChangeCount})`}
</Button>
{hasChanges && (
<Dropdown menu={{ items: [{ key: 'preview-sql', label: '生成预览 SQL', icon: <ConsoleSqlOutlined />, onClick: onPreviewChanges }] }}>
<Button icon={<ConsoleSqlOutlined />}>SQL <DownOutlined /></Button>
</Dropdown>
)}
{hasChanges && <Button icon={<UndoOutlined />} onClick={onResetPendingChanges}></Button>}
</>
)}
{(canImport || canExport) && (
<>
{renderToolbarDivider()}
{canImport && <Button icon={<ImportOutlined />} onClick={onImport}></Button>}
{canExport && <Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}> <DownOutlined /></Button></Dropdown>}
</>
)}
{isQueryResultExport && (
<>
{renderToolbarDivider()}
<Button
data-grid-query-copy-action="true"
icon={<CopyOutlined />}
disabled={!canCopyQueryResult}
onClick={onCopyQueryResultCsv}
>
</Button>
<Dropdown menu={{ items: queryResultCopyMenu }} disabled={!canCopyQueryResult}>
<Button icon={<DownOutlined />} disabled={!canCopyQueryResult} />
</Dropdown>
</>
)}
<>
{renderToolbarDivider()}
<Tooltip title="一键借助 AI 智能分析当前查询页数据">
<Button
className={isV2Ui ? 'gn-v2-ai-insight-button' : undefined}
icon={<RobotOutlined />}
style={legacyAiButtonStyle}
onMouseEnter={(event) => {
if (isV2Ui) return;
event.currentTarget.style.background = darkMode ? 'linear-gradient(135deg, rgba(16,185,129,0.25), rgba(16,185,129,0.1))' : 'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(16,185,129,0.05))';
event.currentTarget.style.borderColor = '#10b981';
}}
onMouseLeave={(event) => {
if (isV2Ui) return;
event.currentTarget.style.background = darkMode ? 'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(16,185,129,0.05))' : 'linear-gradient(135deg, rgba(16,185,129,0.1), rgba(16,185,129,0.02))';
event.currentTarget.style.borderColor = darkMode ? 'rgba(16,185,129,0.3)' : 'rgba(16,185,129,0.4)';
}}
onClick={onRequestAiInsight}
>
<span>{isV2Ui ? 'AI 洞察' : 'AI 数据洞察'}</span>
{isV2Ui && aiShortcutLabel !== '-' && <span className="gn-v2-toolbar-kbd">{aiShortcutLabel}</span>}
</Button>
</Tooltip>
</>
{prefersManualTotalCount && (
<>
{renderToolbarDivider()}
<Tooltip title={paginationTotalCountLoading ? '取消本次精确总数统计(不会影响当前浏览)' : '按当前筛选统计精确总数'}>
<Button
icon={paginationTotalCountLoading ? <CloseOutlined /> : <VerticalAlignBottomOutlined />}
onClick={onToggleTotalCount}
>
{paginationTotalCountLoading ? '取消统计' : '统计总数'}
</Button>
</Tooltip>
</>
)}
<div style={{ marginLeft: 'auto' }} />
</div>
{showFilter && (
<div
ref={filterPanelRef}
className={isV2Ui ? 'gn-v2-smart-filter-panel' : undefined}
style={{
padding: `${filterTopPadding}px ${panelPaddingX}px ${panelPaddingY}px ${panelPaddingX}px`,
background: 'transparent',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
}}
>
<div
data-grid-quick-where="true"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 12px',
marginBottom: 10,
borderRadius: Math.max(10, panelRadius - 2),
border: `1px solid ${panelFrameColor}`,
background: darkMode ? 'rgba(255,255,255,0.035)' : 'rgba(255,255,255,0.72)',
boxSizing: 'border-box',
minWidth: 0,
}}
>
<span
style={{
flex: '0 0 auto',
minWidth: 58,
height: 28,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 999,
background: darkMode ? 'rgba(24,144,255,0.18)' : 'rgba(24,144,255,0.10)',
border: `1px solid ${darkMode ? 'rgba(24,144,255,0.32)' : 'rgba(24,144,255,0.22)'}`,
color: selectionAccentHex,
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.03em',
}}
>
WHERE
</span>
<AutoComplete
value={quickWhereDraft}
options={quickWhereSuggestionOptions}
onChange={onQuickWhereDraftChange}
onOpenChange={onQuickWhereSuggestionsOpenChange}
onInputKeyDown={onQuickWhereKeyDown}
onSelect={onQuickWhereSelect}
style={{ flex: '1 1 320px', minWidth: 220 }}
popupMatchSelectWidth={420}
>
<Input
{...noAutoCapInputProps}
allowClear
data-grid-quick-where-input="true"
onCopy={onQuickWhereCopy}
onCut={onQuickWhereCut}
onPaste={onQuickWherePaste}
placeholder={quickWherePlaceholder}
/>
</AutoComplete>
<Button size="small" type="primary" onClick={onApplyQuickWhere}>
WHERE
</Button>
<Button size="small" onClick={onClearQuickWhere} disabled={!quickWhereDraft && !quickWhereCondition}>
</Button>
</div>
<div style={{ maxHeight: 200, overflowY: 'auto', overflowX: 'hidden', flex: '0 1 auto' }}>
{filterConditions.map((cond, condIndex) => (
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'flex-start', opacity: cond.enabled === false ? 0.58 : 1 }}>
<Checkbox
checked={cond.enabled !== false}
onChange={(event) => updateFilter(cond.id, 'enabled', event.target.checked)}
style={{ marginTop: 6, flex: '0 0 auto', whiteSpace: 'nowrap' }}
>
</Checkbox>
<Select
style={{ width: 96, minWidth: 96, maxWidth: 96, flex: '0 0 96px' }}
value={condIndex === 0 ? '__FIRST__' : (cond.logic === 'OR' ? 'OR' : 'AND')}
onChange={(value) => updateFilter(cond.id, 'logic', value)}
options={condIndex === 0 ? [{ value: '__FIRST__', label: '首条' }] : filterLogicOptions}
disabled={condIndex === 0}
/>
<Select
style={filterFieldSelectStyle}
value={cond.column}
onChange={(value) => updateFilter(cond.id, 'column', value)}
options={gridFieldSelectOptions}
showSearch
optionFilterProp="label"
optionRender={renderGridFieldSelectOption}
popupMatchSelectWidth={filterFieldPopupWidth}
filterOption={(input, option) =>
String(option?.label ?? '')
.toLowerCase()
.includes(String(input || '').trim().toLowerCase())
}
placeholder="搜索字段名"
disabled={cond.op === 'CUSTOM'}
/>
<Select
style={{ width: 140 }}
value={cond.op}
onChange={(value) => updateFilter(cond.id, 'op', value)}
options={filterOpOptions}
/>
{cond.op === 'CUSTOM' ? (
<Input.TextArea
{...noAutoCapInputProps}
style={{ flex: 1 }}
autoSize={{ minRows: 1, maxRows: 4 }}
value={cond.value}
onChange={(event) => updateFilter(cond.id, 'value', event.target.value)}
placeholder="输入自定义 WHERE 表达式(不需要再写 WHERE例如status IN ('A','B')"
/>
) : isListOp(cond.op) ? (
<Input.TextArea
{...noAutoCapInputProps}
style={{ flex: 1 }}
autoSize={{ minRows: 1, maxRows: 4 }}
value={cond.value}
onChange={(event) => updateFilter(cond.id, 'value', event.target.value)}
placeholder="多个值用逗号或换行分隔"
/>
) : isBetweenOp(cond.op) ? (
<>
<Input
{...noAutoCapInputProps}
style={{ width: 220 }}
value={cond.value}
onChange={(event) => updateFilter(cond.id, 'value', event.target.value)}
placeholder="开始值"
/>
<Input
{...noAutoCapInputProps}
style={{ width: 220 }}
value={cond.value2 || ''}
onChange={(event) => updateFilter(cond.id, 'value2', event.target.value)}
placeholder="结束值"
/>
</>
) : isNoValueOp(cond.op) ? (
<Input {...noAutoCapInputProps} style={{ width: 220 }} value="" disabled placeholder="无需输入值" />
) : (
<Input
{...noAutoCapInputProps}
style={{ width: 280 }}
value={cond.value}
onChange={(event) => updateFilter(cond.id, 'value', event.target.value)}
/>
)}
<Button icon={<CloseOutlined />} onClick={() => removeFilter(cond.id)} type="text" danger />
</div>
))}
{enableSortControls && (
<div style={{ paddingTop: filterConditions.length > 0 ? 4 : 0, borderTop: filterConditions.length > 0 && sortInfo.length > 0 ? `1px dashed ${panelFrameColor}` : 'none' }}>
{sortInfo.map((item, index) => (
<div key={`${item.columnKey || 'sort'}-${index}`} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center', opacity: item.enabled === false ? 0.58 : 1 }}>
<Checkbox
checked={item.enabled !== false}
onChange={(event) => {
const next = [...sortInfo];
next[index] = { ...next[index], enabled: event.target.checked };
onApplySortInfo(next);
}}
style={{ flex: '0 0 auto' }}
/>
<span style={{ fontSize: 12, color: 'inherit', opacity: 0.7, whiteSpace: 'nowrap', minWidth: 32 }}>
{index === 0 ? '排序' : '然后'}
</span>
<Select
style={filterFieldSelectStyle}
value={item.columnKey || undefined}
onChange={(value) => {
const next = [...sortInfo];
if (!value) {
next.splice(index, 1);
} else {
next[index] = { ...next[index], columnKey: value };
}
onApplySortInfo(next.filter((entry) => entry.columnKey));
}}
options={displayColumnNames
.filter((columnName) => columnName === item.columnKey || !sortInfo.some((entry) => entry.columnKey === columnName))
.map((columnName) => ({ value: columnName, label: columnName, title: columnName }))}
showSearch
optionFilterProp="label"
optionRender={renderGridFieldSelectOption}
popupMatchSelectWidth={filterFieldPopupWidth}
filterOption={(input, option) =>
String(option?.label ?? '')
.toLowerCase()
.includes(String(input || '').trim().toLowerCase())
}
placeholder="选择排序字段"
allowClear
onClear={() => {
const next = sortInfo.filter((_, itemIndex) => itemIndex !== index);
onApplySortInfo(next);
}}
/>
<Select
style={{ width: 110 }}
value={item.order || 'ascend'}
onChange={(value) => {
const next = [...sortInfo];
next[index] = { ...next[index], order: value };
onApplySortInfo(next);
}}
options={[
{ value: 'ascend', label: '升序 ↑' },
{ value: 'descend', label: '降序 ↓' },
]}
disabled={!item.columnKey}
/>
<Button
icon={<CloseOutlined />}
type="text"
danger
size="small"
onClick={() => onApplySortInfo(sortInfo.filter((_, itemIndex) => itemIndex !== index))}
/>
</div>
))}
</div>
)}
</div>
<div
style={{
display: 'flex',
gap: 8,
flexWrap: 'wrap',
alignItems: 'center',
flex: '0 0 auto',
marginTop: ((enableSortControls && sortInfo.length > 0) || filterConditions.length > 0) ? 4 : 0,
paddingTop: ((enableSortControls && sortInfo.length > 0) || filterConditions.length > 0) ? 6 : 0,
borderTop: ((enableSortControls && sortInfo.length > 0) || filterConditions.length > 0) ? `1px dashed ${panelFrameColor}` : 'none',
}}
>
<Button type="primary" ghost onClick={addFilter} size="small" icon={<PlusOutlined />}></Button>
{enableSortControls && (
<Button
type="dashed"
size="small"
icon={<PlusOutlined />}
onClick={() => {
const nextColumn = displayColumnNames.find((columnName) => !sortInfo.some((item) => item.columnKey === columnName)) || displayColumnNames[0] || '';
onApplySortInfo([...sortInfo, { columnKey: nextColumn, order: 'ascend', enabled: true }]);
}}
disabled={sortInfo.length >= displayColumnNames.length}
>
</Button>
)}
<div style={{ width: 1, height: 16, background: panelFrameColor, margin: '0 2px', flexShrink: 0 }} />
<Button size="small" onClick={onEnableAllFilters}></Button>
<Button size="small" onClick={onDisableAllFilters}></Button>
<div style={{ width: 1, height: 16, background: panelFrameColor, margin: '0 2px', flexShrink: 0 }} />
<Button type="primary" onClick={onApplyFilters} size="small"></Button>
<Button size="small" icon={<ClearOutlined />} onClick={onClearFiltersAndSorts}></Button>
</div>
</div>
)}
</div>
);
};
export default DataGridToolbarFrame;

View File

@@ -0,0 +1,127 @@
import React from 'react';
import { Button, Segmented } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import Editor from './MonacoEditor';
type DdlViewLayoutMode = 'bottom' | 'side';
export interface DataGridV2DdlViewProps {
layout: DdlViewLayoutMode;
tableName?: string;
ddlViewLayout: DdlViewLayoutMode;
ddlLoading: boolean;
ddlText: string;
darkMode: boolean;
onDdlViewLayoutChange: (layout: DdlViewLayoutMode) => void;
onReload: () => void;
onCopy: () => void;
}
export const DataGridV2DdlView: React.FC<DataGridV2DdlViewProps> = ({
layout,
tableName,
ddlViewLayout,
ddlLoading,
ddlText,
darkMode,
onDdlViewLayoutChange,
onReload,
onCopy,
}) => (
<div data-grid-ddl-view={layout} className={`gn-v2-data-grid-ddl-view${layout === 'side' ? ' is-side' : ''}`}>
<div className="gn-v2-data-grid-alt-toolbar">
<div>
<span>DDL</span>
<strong>{tableName ? `DDL - ${tableName}` : 'DDL'}</strong>
</div>
<div>
<Segmented
size="small"
value={ddlViewLayout}
options={[
{ label: '底部', value: 'bottom' },
{ label: '侧栏', value: 'side' },
]}
onChange={(value) => onDdlViewLayoutChange(String(value) as DdlViewLayoutMode)}
/>
<Button size="small" onClick={onReload} loading={ddlLoading}>
</Button>
<Button size="small" icon={<CopyOutlined />} onClick={onCopy} disabled={!ddlText.trim()}>
DDL
</Button>
{layout === 'side' && (
<Button size="small" onClick={() => onDdlViewLayoutChange('bottom')}>
</Button>
)}
</div>
</div>
<div className="gn-v2-data-grid-ddl-code">
<Editor
height="100%"
language="sql"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={ddlLoading ? '正在加载 DDL...' : ddlText}
options={{
readOnly: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'off',
fontSize: 12,
tabSize: 2,
automaticLayout: true,
}}
/>
</div>
</div>
);
export interface DataGridV2DdlSideWorkspaceProps extends Omit<DataGridV2DdlViewProps, 'layout'> {
tableContent: React.ReactNode;
ddlSidebarWidth: number;
ddlSidebarResizePreviewX: number | null;
onResizeStart: (event: React.MouseEvent<HTMLDivElement>) => void;
}
export const DataGridV2DdlSideWorkspace: React.FC<DataGridV2DdlSideWorkspaceProps> = ({
tableContent,
ddlSidebarWidth,
ddlSidebarResizePreviewX,
onResizeStart,
...ddlViewProps
}) => (
<div
data-grid-ddl-layout="side"
className="gn-v2-data-grid-split-workspace"
style={{
gridTemplateColumns: `minmax(0, 1fr) 8px ${ddlSidebarWidth}px`,
'--gn-v2-ddl-sidebar-width': `${ddlSidebarWidth}px`,
} as React.CSSProperties}
>
<div className="gn-v2-data-grid-split-main">
{tableContent}
</div>
<div
data-grid-ddl-resizer="true"
className="gn-v2-data-grid-ddl-resizer"
role="separator"
aria-orientation="vertical"
aria-valuemin={320}
aria-valuemax={760}
aria-valuenow={ddlSidebarWidth}
onMouseDown={onResizeStart}
/>
<aside aria-label="表 DDL 侧栏" className="gn-v2-data-grid-ddl-sidebar">
<DataGridV2DdlView layout="side" {...ddlViewProps} />
</aside>
<div
data-grid-ddl-resize-preview="true"
className="gn-v2-data-grid-ddl-resize-preview"
style={{
opacity: ddlSidebarResizePreviewX === null ? 0 : 1,
transform: ddlSidebarResizePreviewX === null ? undefined : `translateX(${ddlSidebarResizePreviewX}px)`,
}}
/>
</div>
);

View File

@@ -0,0 +1,92 @@
import React from 'react';
export interface DataGridV2FieldsViewProps {
tableName?: string;
displayOutputColumnNames: string[];
pkColumns: string[];
locatorColumns?: string[];
columnMetaMap: Record<string, { type?: string; comment?: string }>;
columnMetaMapByLowerName: Record<string, { type?: string; comment?: string }>;
}
export const DataGridV2FieldsView: React.FC<DataGridV2FieldsViewProps> = ({
tableName,
displayOutputColumnNames,
pkColumns,
locatorColumns,
columnMetaMap,
columnMetaMapByLowerName,
}) => (
<div className="gn-v2-data-grid-fields-view">
<div className="gn-v2-data-grid-fields-head">
<div>
<span>FIELDS</span>
<strong>{tableName || '查询结果'}</strong>
</div>
<div>
<span>{displayOutputColumnNames.length} </span>
</div>
</div>
<div className="gn-v2-data-grid-fields-table">
<div className="gn-v2-data-grid-fields-row is-head">
<span>#</span>
<span></span>
<span></span>
<span>NN</span>
<span>PK</span>
<span></span>
<span></span>
</div>
{displayOutputColumnNames.map((columnName, index) => {
const meta = columnMetaMap[columnName] || columnMetaMapByLowerName[columnName.toLowerCase()];
const isPk = pkColumns.includes(columnName) || locatorColumns?.includes(columnName);
return (
<div className="gn-v2-data-grid-fields-row" key={columnName}>
<span>{index + 1}</span>
<span className="gn-v2-data-grid-field-name">{columnName}</span>
<span className="gn-v2-data-grid-field-type">{meta?.type || '-'}</span>
<span>-</span>
<span>{isPk ? <em>PK</em> : '-'}</span>
<span>-</span>
<span>{meta?.comment || '-'}</span>
</div>
);
})}
</div>
</div>
);
export interface DataGridV2ErViewProps {
tableName?: string;
displayOutputColumnNames: string[];
columnMetaMap: Record<string, { type?: string; comment?: string }>;
columnMetaMapByLowerName: Record<string, { type?: string; comment?: string }>;
}
export const DataGridV2ErView: React.FC<DataGridV2ErViewProps> = ({
tableName,
displayOutputColumnNames,
columnMetaMap,
columnMetaMapByLowerName,
}) => (
<div className="gn-v2-data-grid-er-view">
<div className="gn-v2-data-grid-er-node is-main">
<span>TABLE</span>
<strong>{tableName || '查询结果'}</strong>
<small>{displayOutputColumnNames.length} fields</small>
</div>
<div className="gn-v2-data-grid-er-lines">
<span />
<span />
</div>
<div className="gn-v2-data-grid-er-side">
{displayOutputColumnNames.slice(0, 6).map((columnName) => (
<div className="gn-v2-data-grid-er-node" key={columnName}>
<span>FIELD</span>
<strong>{columnName}</strong>
<small>{(columnMetaMap[columnName] || columnMetaMapByLowerName[columnName.toLowerCase()])?.type || '-'}</small>
</div>
))}
</div>
</div>
);

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
import {
calculateTableBodyBottomPadding,
calculateVirtualTableScrollX,
resolveDataGridHorizontalWheelDelta,
} from './dataGridLayout';
describe('dataGridLayout helpers', () => {
it('returns zero bottom padding without horizontal overflow', () => {
@@ -29,4 +33,30 @@ describe('dataGridLayout helpers', () => {
expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 0, isMacLike: false })).toBe(646);
expect(calculateVirtualTableScrollX({ totalWidth: 1200, tableViewportWidth: 800, isMacLike: true })).toBe(1202);
});
it('only treats wheel gestures as horizontal when the horizontal intent is strong enough', () => {
expect(resolveDataGridHorizontalWheelDelta({
deltaX: 18,
deltaY: 3,
shiftKey: false,
})).toBe(18);
expect(resolveDataGridHorizontalWheelDelta({
deltaX: 2,
deltaY: 24,
shiftKey: false,
})).toBe(0);
expect(resolveDataGridHorizontalWheelDelta({
deltaX: 0.2,
deltaY: 16,
shiftKey: false,
})).toBe(0);
expect(resolveDataGridHorizontalWheelDelta({
deltaX: 0,
deltaY: 20,
shiftKey: true,
})).toBe(20);
});
});

View File

@@ -10,8 +10,16 @@ export interface VirtualTableScrollXOptions {
isMacLike: boolean;
}
export interface DataGridHorizontalWheelIntentOptions {
deltaX: number;
deltaY: number;
shiftKey: boolean;
}
const MIN_SCROLLBAR_CLEARANCE = 8;
const FLOATING_SCROLLBAR_VISUAL_EXTRA = 4;
const HORIZONTAL_WHEEL_MIN_DELTA = 0.5;
const HORIZONTAL_WHEEL_DOMINANCE_RATIO = 1.35;
export const calculateTableBodyBottomPadding = ({
hasHorizontalOverflow,
@@ -46,3 +54,30 @@ export const calculateVirtualTableScrollX = ({
return safeTotalWidth;
};
export const resolveDataGridHorizontalWheelDelta = ({
deltaX,
deltaY,
shiftKey,
}: DataGridHorizontalWheelIntentOptions): number => {
const safeDeltaX = Number.isFinite(deltaX) ? deltaX : 0;
const safeDeltaY = Number.isFinite(deltaY) ? deltaY : 0;
const absX = Math.abs(safeDeltaX);
const absY = Math.abs(safeDeltaY);
if (shiftKey && absY >= HORIZONTAL_WHEEL_MIN_DELTA) {
return safeDeltaY;
}
if (absX < HORIZONTAL_WHEEL_MIN_DELTA) {
return 0;
}
// 触摸板纵向滚动常会夹带微小 deltaX。
// 只有横向位移明显占优时才拦截为横向滚动,避免误伤垂直滚动流畅度。
if (absY > 0 && absX < absY * HORIZONTAL_WHEEL_DOMINANCE_RATIO) {
return 0;
}
return safeDeltaX;
};

View File

@@ -0,0 +1,194 @@
import React from 'react';
import { DBShowCreateTable } from '../../wailsjs/go/app/App';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er';
type DdlViewLayoutMode = 'bottom' | 'side';
interface UseDataGridDdlViewParams {
canViewDdl: boolean;
currentConnConfig: unknown;
dbName?: string;
tableName?: string;
isV2Ui: boolean;
cellEditMode: boolean;
selectedRowKeys: React.Key[];
mergedDisplayDataRef: React.MutableRefObject<any[]>;
rowKeyStr: (key: React.Key) => string;
closeCellEditModeRef: React.MutableRefObject<() => void>;
setTextRecordIndex: React.Dispatch<React.SetStateAction<number>>;
messageApi: {
error: (content: string) => void;
};
}
export interface UseDataGridDdlViewResult {
viewMode: GridViewMode;
setViewMode: React.Dispatch<React.SetStateAction<GridViewMode>>;
ddlModalOpen: boolean;
setDdlModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
ddlLoading: boolean;
ddlText: string;
ddlViewLayout: DdlViewLayoutMode;
setDdlViewLayout: React.Dispatch<React.SetStateAction<DdlViewLayoutMode>>;
ddlSidebarWidth: number;
ddlSidebarResizePreviewX: number | null;
ddlRequestSeqRef: React.MutableRefObject<number>;
isTableSurfaceActive: boolean;
handleOpenTableDdl: (options?: { asView?: boolean }) => Promise<void>;
handleViewModeChange: (nextMode: GridViewMode) => void;
handleDdlSidebarResizeStart: (event: React.MouseEvent<HTMLDivElement>) => void;
resetDdlViewState: () => void;
}
export const useDataGridDdlView = ({
canViewDdl,
currentConnConfig,
dbName,
tableName,
isV2Ui,
cellEditMode,
selectedRowKeys,
mergedDisplayDataRef,
rowKeyStr,
closeCellEditModeRef,
setTextRecordIndex,
messageApi,
}: UseDataGridDdlViewParams): UseDataGridDdlViewResult => {
const [viewMode, setViewMode] = React.useState<GridViewMode>('table');
const [ddlModalOpen, setDdlModalOpen] = React.useState(false);
const [ddlLoading, setDdlLoading] = React.useState(false);
const [ddlText, setDdlText] = React.useState('');
const [ddlViewLayout, setDdlViewLayout] = React.useState<DdlViewLayoutMode>('bottom');
const [ddlSidebarWidth, setDdlSidebarWidth] = React.useState(420);
const [ddlSidebarResizePreviewX, setDdlSidebarResizePreviewX] = React.useState<number | null>(null);
const ddlSidebarResizeRef = React.useRef<{
startX: number;
startWidth: number;
previewWidth: number;
moveHandler?: (event: MouseEvent) => void;
upHandler?: () => void;
} | null>(null);
const ddlRequestSeqRef = React.useRef(0);
const isTableSurfaceActive = viewMode === 'table' || (isV2Ui && viewMode === 'ddl' && ddlViewLayout === 'side');
const handleOpenTableDdl = React.useCallback(async (options?: { asView?: boolean }) => {
if (!canViewDdl || !currentConnConfig || !tableName) {
messageApi.error('当前表缺少连接或表名,无法查看 DDL');
return;
}
const asView = options?.asView === true && isV2Ui;
const requestSeq = ++ddlRequestSeqRef.current;
if (asView) {
setViewMode('ddl');
setDdlModalOpen(false);
} else {
setDdlModalOpen(true);
}
setDdlLoading(true);
setDdlText('');
try {
const res = await DBShowCreateTable(buildRpcConnectionConfig(currentConnConfig as any) as any, dbName || '', tableName);
if (requestSeq !== ddlRequestSeqRef.current) return;
if (res.success) {
setDdlText(String(res.data ?? ''));
return;
}
messageApi.error(res.message || '获取 DDL 失败');
} catch (error: any) {
if (requestSeq !== ddlRequestSeqRef.current) return;
messageApi.error(error?.message || '获取 DDL 失败');
} finally {
if (requestSeq === ddlRequestSeqRef.current) {
setDdlLoading(false);
}
}
}, [canViewDdl, currentConnConfig, dbName, isV2Ui, messageApi, tableName]);
React.useEffect(() => {
if (isV2Ui || (viewMode !== 'fields' && viewMode !== 'ddl' && viewMode !== 'er')) return;
setViewMode('table');
}, [isV2Ui, viewMode]);
const handleViewModeChange = React.useCallback((nextMode: GridViewMode) => {
if ((nextMode === 'fields' || nextMode === 'ddl' || nextMode === 'er') && !isV2Ui) {
setViewMode('table');
return;
}
if (nextMode === 'ddl') {
void handleOpenTableDdl({ asView: true });
setViewMode('ddl');
return;
}
if (nextMode === 'json' && cellEditMode) {
closeCellEditModeRef.current();
}
if (nextMode === 'text') {
const selectedKey = selectedRowKeys[0];
if (selectedKey !== undefined) {
const idx = mergedDisplayDataRef.current.findIndex((row) => rowKeyStr(row?.__gonavi_row_key__) === rowKeyStr(selectedKey));
if (idx >= 0) {
setTextRecordIndex(idx);
}
}
}
setViewMode(nextMode);
}, [cellEditMode, closeCellEditModeRef, handleOpenTableDdl, isV2Ui, mergedDisplayDataRef, rowKeyStr, selectedRowKeys, setTextRecordIndex]);
const handleDdlSidebarResizeStart = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = ddlSidebarWidth;
const moveHandler = (moveEvent: MouseEvent) => {
const nextWidth = Math.max(320, Math.min(760, startWidth + (startX - moveEvent.clientX)));
if (ddlSidebarResizeRef.current) {
ddlSidebarResizeRef.current.previewWidth = nextWidth;
}
setDdlSidebarResizePreviewX(moveEvent.clientX);
};
const upHandler = () => {
const nextWidth = ddlSidebarResizeRef.current?.previewWidth ?? startWidth;
setDdlSidebarWidth(nextWidth);
setDdlSidebarResizePreviewX(null);
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
ddlSidebarResizeRef.current = null;
};
ddlSidebarResizeRef.current = { startX, startWidth, previewWidth: startWidth, moveHandler, upHandler };
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}, [ddlSidebarWidth]);
const resetDdlViewState = React.useCallback(() => {
ddlRequestSeqRef.current += 1;
setDdlModalOpen(false);
setDdlLoading(false);
setDdlText('');
setDdlViewLayout('bottom');
setDdlSidebarResizePreviewX(null);
}, []);
return {
viewMode,
setViewMode,
ddlModalOpen,
setDdlModalOpen,
ddlLoading,
ddlText,
ddlViewLayout,
setDdlViewLayout,
ddlSidebarWidth,
ddlSidebarResizePreviewX,
ddlRequestSeqRef,
isTableSurfaceActive,
handleOpenTableDdl,
handleViewModeChange,
handleDdlSidebarResizeStart,
resetDdlViewState,
};
};

View File

@@ -0,0 +1,379 @@
import React from 'react';
import type { FilterCondition } from '../utils/sql';
import { applyNoAutoCapAttributesWithin } from '../utils/inputAutoCap';
import {
normalizeQuickWhereCondition,
resolveWhereConditionSuggestions,
validateQuickWhereCondition,
} from '../utils/dataGridWhereFilter';
export type GridFilterConditionState = FilterCondition & {
id: number;
column: string;
op: string;
value: string;
value2?: string;
};
type GridSortInfo = {
columnKey: string;
order: string;
enabled?: boolean;
};
interface UseDataGridFiltersParams {
appliedFilterConditions?: FilterCondition[];
quickWhereCondition?: string;
showFilter?: boolean;
displayColumnNames: string[];
allTableColumnNames: string[];
columnMetaMap: Record<string, unknown>;
dbType: string;
darkMode: boolean;
onApplyFilter?: (conditions: GridFilterConditionState[]) => void;
onApplyQuickWhereCondition?: (condition: string) => void;
onSort?: (field: string, order: string) => void;
messageApi?: {
warning?: (content: string) => void;
};
getColumnFilterType: (columnName: string) => string;
resolveDefaultGridFilterOperator: (columnType: unknown) => string;
resolveNextGridFilterOperatorForColumnChange: (params: {
currentOperator: unknown;
previousColumnType: unknown;
nextColumnType: unknown;
}) => string;
}
export interface UseDataGridFiltersResult {
filterConditions: GridFilterConditionState[];
setFilterConditions: React.Dispatch<React.SetStateAction<GridFilterConditionState[]>>;
quickWhereDraft: string;
setQuickWhereDraft: React.Dispatch<React.SetStateAction<string>>;
quickWhereSuggestionsOpen: boolean;
setQuickWhereSuggestionsOpen: React.Dispatch<React.SetStateAction<boolean>>;
filterPanelRef: React.RefObject<HTMLDivElement>;
filterOpOptions: Array<{ value: string; label: string }>;
filterLogicOptions: Array<{ value: string; label: string }>;
quickWhereSuggestionOptions: Array<{ value: string; insertText: string; suggestionKind: string; label: React.ReactNode }>;
handleQuickWherePaste: (event: React.ClipboardEvent<HTMLInputElement>) => void;
stopQuickWhereClipboardPropagation: (event: React.ClipboardEvent<HTMLInputElement>) => void;
isNoValueOp: (op: string) => boolean;
isBetweenOp: (op: string) => boolean;
isListOp: (op: string) => boolean;
addFilter: () => void;
updateFilter: (id: number, field: keyof GridFilterConditionState, val: string | boolean) => void;
removeFilter: (id: number) => void;
applyQuickWhereCondition: (condition?: string) => boolean;
clearQuickWhereCondition: () => void;
clearAllFiltersAndSorts: () => void;
applyFilters: () => void;
applyAllFiltersEnabled: () => void;
applyAllFiltersDisabled: () => void;
}
const EXACT_GRID_FILTER_OPERATOR = '=';
export const useDataGridFilters = ({
appliedFilterConditions,
quickWhereCondition,
showFilter,
displayColumnNames,
allTableColumnNames,
columnMetaMap,
dbType,
darkMode,
onApplyFilter,
onApplyQuickWhereCondition,
onSort,
messageApi,
getColumnFilterType,
resolveDefaultGridFilterOperator,
resolveNextGridFilterOperatorForColumnChange,
}: UseDataGridFiltersParams): UseDataGridFiltersResult => {
const normalizeFilterLogic = React.useCallback((logic: unknown): 'AND' | 'OR' => {
return String(logic || '').trim().toUpperCase() === 'OR' ? 'OR' : 'AND';
}, []);
const firstColumnNameRef = React.useRef(displayColumnNames[0] || '');
firstColumnNameRef.current = displayColumnNames[0] || '';
const normalizeGridFilterConditions = React.useCallback((conditions?: FilterCondition[]): GridFilterConditionState[] => {
if (!Array.isArray(conditions)) return [];
return conditions.map((cond, index) => {
const fallbackId = index + 1;
const nextId = Number.isFinite(Number(cond?.id)) ? Number(cond?.id) : fallbackId;
const op = String(cond?.op || EXACT_GRID_FILTER_OPERATOR);
const rawColumn = String(cond?.column || '');
return {
id: nextId,
enabled: cond?.enabled !== false,
logic: normalizeFilterLogic(cond?.logic),
column: rawColumn || (op === 'CUSTOM' ? '' : String(firstColumnNameRef.current || '')),
op,
value: String(cond?.value ?? ''),
value2: String(cond?.value2 ?? ''),
};
});
}, [normalizeFilterLogic]);
const [filterConditions, setFilterConditions] = React.useState<GridFilterConditionState[]>([]);
const [nextFilterId, setNextFilterId] = React.useState(1);
const [quickWhereDraft, setQuickWhereDraft] = React.useState(() => normalizeQuickWhereCondition(quickWhereCondition));
const [quickWhereSuggestionsOpen, setQuickWhereSuggestionsOpen] = React.useState(false);
const filterPanelRef = React.useRef<HTMLDivElement>(null);
const autoDefaultFilterIdsRef = React.useRef<Set<number>>(new Set());
React.useEffect(() => {
const nextConditions = normalizeGridFilterConditions(appliedFilterConditions);
autoDefaultFilterIdsRef.current.clear();
setFilterConditions(nextConditions);
const maxId = nextConditions.reduce((max, cond) => (cond.id > max ? cond.id : max), 0);
setNextFilterId(Math.max(1, maxId + 1));
}, [appliedFilterConditions, normalizeGridFilterConditions]);
React.useEffect(() => {
setQuickWhereDraft(normalizeQuickWhereCondition(quickWhereCondition));
}, [quickWhereCondition]);
React.useEffect(() => {
if (Object.keys(columnMetaMap).length === 0) return;
setFilterConditions((prev) => {
let changed = false;
const nextConditions = prev.map((cond) => {
if (!autoDefaultFilterIdsRef.current.has(cond.id)) {
return cond;
}
const nextOp = resolveDefaultGridFilterOperator(getColumnFilterType(cond.column));
if (nextOp === cond.op) return cond;
changed = true;
return { ...cond, op: nextOp };
});
return changed ? nextConditions : prev;
});
}, [columnMetaMap, getColumnFilterType, resolveDefaultGridFilterOperator]);
const quickWhereSuggestionOptions = React.useMemo(() => {
const columnSuggestionSource = allTableColumnNames.length > 0 ? allTableColumnNames : displayColumnNames;
return resolveWhereConditionSuggestions({
input: quickWhereDraft,
columnNames: columnSuggestionSource,
dbType,
}).map((item) => ({
value: item.value,
insertText: item.insertText,
suggestionKind: item.kind,
label: (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<span>{item.label}</span>
<span style={{ color: darkMode ? 'rgba(255,255,255,0.46)' : 'rgba(0,0,0,0.42)', fontSize: 12 }}>{item.detail}</span>
</div>
),
}));
}, [allTableColumnNames, displayColumnNames, quickWhereDraft, dbType, darkMode]);
const handleQuickWherePaste = React.useCallback((event: React.ClipboardEvent<HTMLInputElement>) => {
const pastedText = event.clipboardData.getData('text/plain') || event.clipboardData.getData('text');
if (!pastedText) return;
event.preventDefault();
event.stopPropagation();
const input = event.currentTarget;
const currentValue = input.value ?? quickWhereDraft;
const start = input.selectionStart ?? currentValue.length;
const end = input.selectionEnd ?? start;
const nextValue = `${currentValue.slice(0, start)}${pastedText}${currentValue.slice(end)}`;
const nextCursor = start + pastedText.length;
setQuickWhereDraft(nextValue);
requestAnimationFrame(() => {
input.focus();
input.setSelectionRange(nextCursor, nextCursor);
});
}, [quickWhereDraft]);
const stopQuickWhereClipboardPropagation = React.useCallback((event: React.ClipboardEvent<HTMLInputElement>) => {
event.stopPropagation();
}, []);
React.useEffect(() => {
if (!showFilter) {
return;
}
const root = filterPanelRef.current;
if (!root) {
return;
}
const apply = () => {
applyNoAutoCapAttributesWithin(root);
};
apply();
if (typeof MutationObserver === 'undefined') {
return;
}
const observer = new MutationObserver(() => {
apply();
});
observer.observe(root, { childList: true, subtree: true });
return () => {
observer.disconnect();
};
}, [showFilter]);
const filterOpOptions = React.useMemo(() => ([
{ value: '=', label: '=' },
{ value: '!=', label: '!=' },
{ value: '<', label: '<' },
{ value: '<=', label: '<=' },
{ value: '>', label: '>' },
{ value: '>=', label: '>=' },
{ value: 'CONTAINS', label: '包含' },
{ value: 'NOT_CONTAINS', label: '不包含' },
{ value: 'STARTS_WITH', label: '开始以' },
{ value: 'NOT_STARTS_WITH', label: '不是开始于' },
{ value: 'ENDS_WITH', label: '结束以' },
{ value: 'NOT_ENDS_WITH', label: '不是结束于' },
{ value: 'IS_NULL', label: '是 null' },
{ value: 'IS_NOT_NULL', label: '不是 null' },
{ value: 'IS_EMPTY', label: '是空的' },
{ value: 'IS_NOT_EMPTY', label: '不是空的' },
{ value: 'BETWEEN', label: '介于' },
{ value: 'NOT_BETWEEN', label: '不介于' },
{ value: 'IN', label: '在列表' },
{ value: 'NOT_IN', label: '不在列表' },
{ value: 'CUSTOM', label: '[自定义]' },
]), []);
const filterLogicOptions = React.useMemo(() => ([
{ value: 'AND', label: '且 (AND)' },
{ value: 'OR', label: '或 (OR)' },
]), []);
const isNoValueOp = React.useCallback((op: string) => (
op === 'IS_NULL' || op === 'IS_NOT_NULL' || op === 'IS_EMPTY' || op === 'IS_NOT_EMPTY'
), []);
const isBetweenOp = React.useCallback((op: string) => op === 'BETWEEN' || op === 'NOT_BETWEEN', []);
const isListOp = React.useCallback((op: string) => op === 'IN' || op === 'NOT_IN', []);
const addFilter = React.useCallback(() => {
const column = displayColumnNames[0] || '';
const id = nextFilterId;
autoDefaultFilterIdsRef.current.add(id);
setFilterConditions((prev) => [
...prev,
{
id,
enabled: true,
logic: 'AND',
column,
op: resolveDefaultGridFilterOperator(getColumnFilterType(column)),
value: '',
value2: '',
},
]);
setNextFilterId((prev) => prev + 1);
}, [displayColumnNames, getColumnFilterType, nextFilterId, resolveDefaultGridFilterOperator]);
const updateFilter = React.useCallback((id: number, field: keyof GridFilterConditionState, val: string | boolean) => {
setFilterConditions((prev) => prev.map((cond) => {
if (cond.id !== id) return cond;
const next: GridFilterConditionState = { ...cond, [field]: val } as GridFilterConditionState;
if (field === 'column') {
next.op = resolveNextGridFilterOperatorForColumnChange({
currentOperator: cond.op,
previousColumnType: getColumnFilterType(cond.column),
nextColumnType: getColumnFilterType(String(val)),
});
if (isNoValueOp(next.op)) {
next.value = '';
next.value2 = '';
} else if (!isBetweenOp(next.op)) {
next.value2 = '';
}
}
if (field === 'op') {
autoDefaultFilterIdsRef.current.delete(id);
const nextOp = String(val);
if (isNoValueOp(nextOp)) {
next.value = '';
next.value2 = '';
} else if (isBetweenOp(nextOp)) {
if (typeof next.value2 !== 'string') next.value2 = '';
} else {
next.value2 = '';
}
}
return next;
}));
}, [getColumnFilterType, isBetweenOp, isNoValueOp, resolveNextGridFilterOperatorForColumnChange]);
const removeFilter = React.useCallback((id: number) => {
autoDefaultFilterIdsRef.current.delete(id);
setFilterConditions((prev) => prev.filter((cond) => cond.id !== id));
}, []);
const applyQuickWhereCondition = React.useCallback((condition: string = quickWhereDraft): boolean => {
const normalized = normalizeQuickWhereCondition(condition);
const validation = validateQuickWhereCondition(normalized);
if (!validation.ok) {
messageApi?.warning?.(validation.message);
return false;
}
setQuickWhereDraft(normalized);
if (onApplyQuickWhereCondition) onApplyQuickWhereCondition(normalized);
return true;
}, [messageApi, onApplyQuickWhereCondition, quickWhereDraft]);
const clearQuickWhereCondition = React.useCallback(() => {
setQuickWhereDraft('');
if (onApplyQuickWhereCondition) onApplyQuickWhereCondition('');
}, [onApplyQuickWhereCondition]);
const clearAllFiltersAndSorts = React.useCallback(() => {
setFilterConditions([]);
clearQuickWhereCondition();
if (onApplyFilter) onApplyFilter([]);
if (onSort) onSort('', '');
}, [clearQuickWhereCondition, onApplyFilter, onSort]);
const applyFilters = React.useCallback(() => {
if (!applyQuickWhereCondition()) return;
if (onApplyFilter) onApplyFilter(filterConditions);
}, [applyQuickWhereCondition, filterConditions, onApplyFilter]);
const applyAllFiltersEnabled = React.useCallback(() => {
setFilterConditions((prev) => prev.map((cond) => ({ ...cond, enabled: true })));
}, []);
const applyAllFiltersDisabled = React.useCallback(() => {
setFilterConditions((prev) => prev.map((cond) => ({ ...cond, enabled: false })));
}, []);
return {
filterConditions,
setFilterConditions,
quickWhereDraft,
setQuickWhereDraft,
quickWhereSuggestionsOpen,
setQuickWhereSuggestionsOpen,
filterPanelRef,
filterOpOptions,
filterLogicOptions,
quickWhereSuggestionOptions,
handleQuickWherePaste,
stopQuickWhereClipboardPropagation,
isNoValueOp,
isBetweenOp,
isListOp,
addFilter,
updateFilter,
removeFilter,
applyQuickWhereCondition,
clearQuickWhereCondition,
clearAllFiltersAndSorts,
applyFilters,
applyAllFiltersEnabled,
applyAllFiltersDisabled,
};
};

View File

@@ -0,0 +1,183 @@
import React from 'react';
import { Form } from 'antd';
type GridRecord = Record<string, any>;
export interface DataGridCellEditorMeta {
record: GridRecord;
dataIndex: string;
title: string;
}
interface OpenRowEditorParams {
rowKey: string;
baseRawMap: Record<string, any>;
displayMap: Record<string, string>;
nullCols: Set<string>;
formValues: Record<string, any>;
}
interface UseDataGridModalEditorsParams {
toEditableText: (value: any) => string;
looksLikeJsonText: (text: string) => boolean;
}
export interface UseDataGridModalEditorsResult {
cellEditorOpen: boolean;
cellEditorValue: string;
setCellEditorValue: React.Dispatch<React.SetStateAction<string>>;
cellEditorIsJson: boolean;
cellEditorMeta: DataGridCellEditorMeta | null;
cellEditorApplyRef: React.MutableRefObject<((val: string) => void) | null>;
closeCellEditor: () => void;
openCellEditor: (
record: GridRecord,
dataIndex: string,
title: React.ReactNode,
onApplyValue?: (val: string) => void,
) => void;
jsonEditorOpen: boolean;
jsonEditorValue: string;
setJsonEditorValue: React.Dispatch<React.SetStateAction<string>>;
openJsonEditor: (value: string) => void;
closeJsonEditor: () => void;
rowEditorOpen: boolean;
rowEditorRowKey: string;
rowEditorBaseRawRef: React.MutableRefObject<Record<string, any>>;
rowEditorDisplayRef: React.MutableRefObject<Record<string, string>>;
rowEditorNullColsRef: React.MutableRefObject<Set<string>>;
rowEditorForm: any;
closeRowEditor: () => void;
openRowEditor: (params: OpenRowEditorParams) => void;
batchEditModalOpen: boolean;
batchEditValue: string;
setBatchEditValue: React.Dispatch<React.SetStateAction<string>>;
batchEditSetNull: boolean;
setBatchEditSetNull: React.Dispatch<React.SetStateAction<boolean>>;
openBatchEditModal: () => void;
closeBatchEditModal: () => void;
}
export const useDataGridModalEditors = ({
toEditableText,
looksLikeJsonText,
}: UseDataGridModalEditorsParams): UseDataGridModalEditorsResult => {
const [cellEditorOpen, setCellEditorOpen] = React.useState(false);
const [cellEditorValue, setCellEditorValue] = React.useState('');
const [cellEditorIsJson, setCellEditorIsJson] = React.useState(false);
const [cellEditorMeta, setCellEditorMeta] = React.useState<DataGridCellEditorMeta | null>(null);
const cellEditorApplyRef = React.useRef<((val: string) => void) | null>(null);
const [jsonEditorOpen, setJsonEditorOpen] = React.useState(false);
const [jsonEditorValue, setJsonEditorValue] = React.useState('');
const [rowEditorOpen, setRowEditorOpen] = React.useState(false);
const [rowEditorRowKey, setRowEditorRowKey] = React.useState<string>('');
const rowEditorBaseRawRef = React.useRef<Record<string, any>>({});
const rowEditorDisplayRef = React.useRef<Record<string, string>>({});
const rowEditorNullColsRef = React.useRef<Set<string>>(new Set());
const [rowEditorForm] = Form.useForm();
const rowEditorFormRef = React.useRef(rowEditorForm);
rowEditorFormRef.current = rowEditorForm;
const [batchEditModalOpen, setBatchEditModalOpen] = React.useState(false);
const [batchEditValue, setBatchEditValue] = React.useState('');
const [batchEditSetNull, setBatchEditSetNull] = React.useState(false);
const closeCellEditor = React.useCallback(() => {
setCellEditorOpen(false);
setCellEditorMeta(null);
setCellEditorValue('');
setCellEditorIsJson(false);
cellEditorApplyRef.current = null;
}, []);
const openCellEditor = React.useCallback((
record: GridRecord,
dataIndex: string,
title: React.ReactNode,
onApplyValue?: (val: string) => void,
) => {
if (!record || !dataIndex) return;
const raw = record?.[dataIndex];
const text = toEditableText(raw);
const isJson = looksLikeJsonText(text);
const titleText = typeof title === 'string'
? title
: (typeof title === 'number' ? String(title) : String(dataIndex));
setCellEditorMeta({ record, dataIndex, title: titleText });
setCellEditorValue(text);
setCellEditorIsJson(isJson);
setCellEditorOpen(true);
cellEditorApplyRef.current = typeof onApplyValue === 'function' ? onApplyValue : null;
}, [looksLikeJsonText, toEditableText]);
const openJsonEditor = React.useCallback((value: string) => {
setJsonEditorValue(value);
setJsonEditorOpen(true);
}, []);
const closeJsonEditor = React.useCallback(() => {
setJsonEditorOpen(false);
}, []);
const closeRowEditor = React.useCallback(() => {
setRowEditorOpen(false);
setRowEditorRowKey('');
rowEditorBaseRawRef.current = {};
rowEditorDisplayRef.current = {};
rowEditorNullColsRef.current = new Set();
rowEditorFormRef.current.resetFields();
}, []);
const openRowEditor = React.useCallback((params: OpenRowEditorParams) => {
rowEditorBaseRawRef.current = params.baseRawMap;
rowEditorDisplayRef.current = params.displayMap;
rowEditorNullColsRef.current = params.nullCols;
rowEditorFormRef.current.setFieldsValue(params.formValues);
setRowEditorRowKey(params.rowKey);
setRowEditorOpen(true);
}, []);
const openBatchEditModal = React.useCallback(() => {
setBatchEditValue('');
setBatchEditSetNull(false);
setBatchEditModalOpen(true);
}, []);
const closeBatchEditModal = React.useCallback(() => {
setBatchEditModalOpen(false);
}, []);
return {
cellEditorOpen,
cellEditorValue,
setCellEditorValue,
cellEditorIsJson,
cellEditorMeta,
cellEditorApplyRef,
closeCellEditor,
openCellEditor,
jsonEditorOpen,
jsonEditorValue,
setJsonEditorValue,
openJsonEditor,
closeJsonEditor,
rowEditorOpen,
rowEditorRowKey,
rowEditorBaseRawRef,
rowEditorDisplayRef,
rowEditorNullColsRef,
rowEditorForm,
closeRowEditor,
openRowEditor,
batchEditModalOpen,
batchEditValue,
setBatchEditValue,
batchEditSetNull,
setBatchEditSetNull,
openBatchEditModal,
closeBatchEditModal,
};
};

View File

@@ -0,0 +1,100 @@
import React from 'react';
type GridRecord = Record<string, any>;
export interface DataGridFocusedCellInfo {
record: GridRecord;
dataIndex: string;
title: string;
}
interface UseDataGridPreviewPanelParams {
toEditableText: (value: any) => string;
looksLikeJsonText: (text: string) => boolean;
normalizeDateTimeString: (value: string) => string;
}
export interface UseDataGridPreviewPanelResult {
dataPanelOpen: boolean;
dataPanelOpenRef: React.MutableRefObject<boolean>;
focusedCellInfo: DataGridFocusedCellInfo | null;
dataPanelValue: string;
setDataPanelValue: React.Dispatch<React.SetStateAction<string>>;
dataPanelIsJson: boolean;
dataPanelDirtyRef: React.MutableRefObject<boolean>;
dataPanelOriginalRef: React.MutableRefObject<string>;
toggleDataPanel: () => void;
updateFocusedCell: (record: GridRecord, dataIndex: string) => void;
handleDataPanelFormatJson: (onError: (message: string) => void) => void;
}
export const useDataGridPreviewPanel = ({
toEditableText,
looksLikeJsonText,
normalizeDateTimeString,
}: UseDataGridPreviewPanelParams): UseDataGridPreviewPanelResult => {
const [dataPanelOpen, setDataPanelOpen] = React.useState(false);
const dataPanelOpenRef = React.useRef(false);
const [focusedCellInfo, setFocusedCellInfo] = React.useState<DataGridFocusedCellInfo | null>(null);
const [dataPanelValue, setDataPanelValue] = React.useState('');
const [dataPanelIsJson, setDataPanelIsJson] = React.useState(false);
const dataPanelDirtyRef = React.useRef(false);
const dataPanelOriginalRef = React.useRef('');
const updateFocusedCell = React.useCallback((record: GridRecord, dataIndex: string) => {
if (!record || !dataIndex) return;
const raw = record?.[dataIndex];
let text = toEditableText(raw);
if (typeof raw === 'string') {
text = normalizeDateTimeString(raw);
}
const isJson = looksLikeJsonText(text);
setFocusedCellInfo({ record, dataIndex, title: dataIndex });
dataPanelOriginalRef.current = text;
setDataPanelValue(text);
setDataPanelIsJson(isJson);
dataPanelDirtyRef.current = false;
}, [looksLikeJsonText, normalizeDateTimeString, toEditableText]);
const handleDataPanelFormatJson = React.useCallback((onError: (message: string) => void) => {
if (!dataPanelIsJson) return;
try {
const obj = JSON.parse(dataPanelValue);
setDataPanelValue(JSON.stringify(obj, null, 2));
dataPanelDirtyRef.current = true;
} catch (error: any) {
onError(error?.message || String(error));
}
}, [dataPanelIsJson, dataPanelValue]);
const toggleDataPanel = React.useCallback(() => {
const next = !dataPanelOpenRef.current;
dataPanelOpenRef.current = next;
setDataPanelOpen(next);
if (!next) {
setFocusedCellInfo(null);
setDataPanelValue('');
setDataPanelIsJson(false);
dataPanelDirtyRef.current = false;
dataPanelOriginalRef.current = '';
}
}, []);
React.useEffect(() => {
dataPanelOpenRef.current = dataPanelOpen;
}, [dataPanelOpen]);
return {
dataPanelOpen,
dataPanelOpenRef,
focusedCellInfo,
dataPanelValue,
setDataPanelValue,
dataPanelIsJson,
dataPanelDirtyRef,
dataPanelOriginalRef,
toggleDataPanel,
updateFocusedCell,
handleDataPanelFormatJson,
};
};

View File

@@ -0,0 +1,157 @@
import React, { useMemo, useState } from 'react';
import { Alert, Button, Card, InputNumber, Select, Space, Typography } from 'antd';
import DataGrid, { GONAVI_ROW_KEY } from '../components/DataGrid';
import type { EditRowLocator } from '../utils/rowLocator';
const { Text } = Typography;
type HarnessRow = Record<string, any> & {
[GONAVI_ROW_KEY]: string;
};
const buildHarnessColumns = (count: number): string[] => {
const safeCount = Math.max(8, Math.min(64, Math.trunc(count || 0)));
return Array.from({ length: safeCount }, (_, index) => {
if (index === 0) return 'id';
if (index === 1) return 'created_at';
if (index === 2) return 'updated_at';
if (index === 3) return 'status';
return `col_${String(index + 1).padStart(2, '0')}`;
});
};
const buildHarnessData = (rowCount: number, columnNames: string[]): HarnessRow[] => {
const safeRows = Math.max(200, Math.min(50000, Math.trunc(rowCount || 0)));
return Array.from({ length: safeRows }, (_, rowIndex) => {
const rowNumber = rowIndex + 1;
const nextRow: HarnessRow = {
[GONAVI_ROW_KEY]: `perf-row-${rowNumber}`,
id: rowNumber,
created_at: `2026-05-${String((rowNumber % 28) + 1).padStart(2, '0')} 09:${String(rowNumber % 60).padStart(2, '0')}:12`,
updated_at: `2026-05-${String((rowNumber % 28) + 1).padStart(2, '0')} 18:${String((rowNumber * 3) % 60).padStart(2, '0')}:45`,
status: rowNumber % 3 === 0 ? 'active' : (rowNumber % 3 === 1 ? 'pending' : 'archived'),
};
columnNames.forEach((columnName, columnIndex) => {
if (Object.prototype.hasOwnProperty.call(nextRow, columnName)) {
return;
}
if (columnIndex % 9 === 0) {
nextRow[columnName] = rowNumber * (columnIndex + 1);
return;
}
if (columnIndex % 7 === 0) {
nextRow[columnName] = JSON.stringify({
row: rowNumber,
col: columnName,
flag: rowIndex % 5 === 0,
});
return;
}
nextRow[columnName] = `${columnName}-value-${rowNumber}-${(columnIndex % 5) + 1}`;
});
return nextRow;
});
};
const HARNESS_EDIT_LOCATOR: EditRowLocator = {
strategy: 'primary-key',
columns: ['id'],
valueColumns: ['id'],
readOnly: false,
};
const PerfDataGridHarness: React.FC = () => {
const [rowCount, setRowCount] = useState(10000);
const [columnCount, setColumnCount] = useState(24);
const [density, setDensity] = useState<'compact' | 'comfortable' | 'spacious'>('comfortable');
const columnNames = useMemo(() => buildHarnessColumns(columnCount), [columnCount]);
const data = useMemo(() => buildHarnessData(rowCount, columnNames), [rowCount, columnNames]);
return (
<div style={{ height: '100vh', overflow: 'hidden', background: '#0b1220', padding: 16, boxSizing: 'border-box' }}>
<Card
style={{
height: '100%',
borderRadius: 12,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
bodyStyle={{
flex: '1 1 auto',
minHeight: 0,
display: 'flex',
flexDirection: 'column',
padding: 12,
gap: 12,
}}
>
<Space wrap align="center" size={12}>
<Text strong>DataGrid </Text>
<InputNumber
min={200}
max={50000}
step={500}
value={rowCount}
onChange={(value) => setRowCount(Number(value) || 10000)}
addonBefore="行数"
/>
<InputNumber
min={8}
max={64}
step={2}
value={columnCount}
onChange={(value) => setColumnCount(Number(value) || 24)}
addonBefore="列数"
/>
<Select
value={density}
style={{ width: 140 }}
onChange={(value) => setDensity(value)}
options={[
{ value: 'compact', label: '紧凑' },
{ value: 'comfortable', label: '标准' },
{ value: 'spacious', label: '宽松' },
]}
/>
<Button
onClick={() => {
window.dispatchEvent(new Event('resize'));
}}
>
</Button>
</Space>
<Alert
type="info"
showIcon
message="这个页面只用于开发态滚动性能采样"
description={`当前 ${data.length} 行 / ${columnNames.length} 列。直接在表格区域做纵向、横向、Shift+滚轮滚动采样。`}
/>
<div style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<DataGrid
data={data}
columnNames={columnNames}
loading={false}
tableName="perf_grid"
dbName="perf_lab"
connectionId="perf-conn"
pkColumns={['id']}
editLocator={HARNESS_EDIT_LOCATOR}
pagination={{
current: 1,
pageSize: data.length,
total: data.length,
totalKnown: true,
}}
onPageChange={() => {}}
/>
</div>
</Card>
</div>
);
};
export default PerfDataGridHarness;

View File

@@ -1,6 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import PerfDataGridHarness from './dev/PerfDataGridHarness'
// import './index.css' // Optional global styles
// 全局配置 dayjs 使用中文 locale使 Ant Design 的 DatePicker/TimePicker 等组件
@@ -11,6 +12,17 @@ dayjs.locale('zh-cn')
import { cloneBrowserMockValue, duplicateBrowserMockConnection, resolveBrowserMockSecretFlag } from './utils/browserMockConnections'
const resolveDevHarnessMode = (): string => {
if (typeof window === 'undefined') {
return '';
}
try {
return new URLSearchParams(window.location.search).get('devHarness') || '';
} catch {
return '';
}
};
if (typeof window !== 'undefined' && !(window as any).go) {
const mockConnections: any[] = [];
let mockGlobalProxy: any = { enabled: false, type: 'socks5', host: '', port: 1080, user: '', password: '', hasPassword: false };
@@ -161,11 +173,16 @@ if (typeof window !== 'undefined' && !(window as any).go) {
}
};
}
ReactDOM.createRoot(document.getElementById('root')!).render(
const rootNode = document.getElementById('root')!;
const devHarnessMode = import.meta.env.DEV ? resolveDevHarnessMode() : '';
const rootComponent = devHarnessMode === 'datagrid-perf'
? <PerfDataGridHarness />
: <App />;
ReactDOM.createRoot(rootNode).render(
<React.StrictMode>
<App />
{rootComponent}
</React.StrictMode>,
)