mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-12 17:39:42 +08:00
🐛 fix(data-grid): 修复数据视图交互与右键菜单问题
- 修复当前页查找高亮、清空与 ESC 取消行为 - 优化单元格编辑器尺寸与选中状态取消逻辑 - 收敛工具栏重复操作并修复右键菜单遮挡 - 补充数据网格布局与右键菜单测试覆盖
This commit is contained in:
@@ -140,6 +140,7 @@ vi.mock('@ant-design/icons', () => {
|
||||
SearchOutlined: Icon,
|
||||
LinkOutlined: Icon,
|
||||
TableOutlined: Icon,
|
||||
AimOutlined: Icon,
|
||||
SortAscendingOutlined: Icon,
|
||||
SortDescendingOutlined: Icon,
|
||||
DatabaseOutlined: Icon,
|
||||
@@ -576,6 +577,7 @@ describe('DataGrid DDL interactions', () => {
|
||||
tableName="users"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
pkColumns={['id']}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -675,6 +677,7 @@ describe('DataGrid DDL interactions', () => {
|
||||
tableName="users"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
pkColumns={['id']}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -872,6 +875,78 @@ describe('DataGrid DDL interactions', () => {
|
||||
renderer!.unmount();
|
||||
});
|
||||
|
||||
it('copies the current row for paste and pastes it as a new row from the v2 cell context menu', async () => {
|
||||
storeState.appearance.uiVersion = 'v2';
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<DataGrid
|
||||
data={[
|
||||
{ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' },
|
||||
]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
pkColumns={['id']}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
const nameColumn = testRenderState.latestColumns.find((column) => column.key === 'name');
|
||||
const contextTarget = {
|
||||
closest: (selector: string) => selector === '[data-row-key][data-col-name]'
|
||||
? {
|
||||
getAttribute: (name: string) => {
|
||||
if (name === 'data-row-key') return 'row-1';
|
||||
if (name === 'data-col-name') return 'name';
|
||||
return null;
|
||||
},
|
||||
}
|
||||
: null,
|
||||
} as unknown as HTMLElement;
|
||||
|
||||
const openMenu = async () => {
|
||||
const cellProps = nameColumn.onCell({ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' });
|
||||
await act(async () => {
|
||||
cellProps.onContextMenu({
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
clientX: 160,
|
||||
clientY: 120,
|
||||
currentTarget: contextTarget,
|
||||
target: contextTarget,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
await openMenu();
|
||||
await act(async () => {
|
||||
findButton(renderer!, '复制本行为新增行').props.onClick({
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(messageApi.success).toHaveBeenCalledWith('已复制 1 行,可粘贴为新增行');
|
||||
|
||||
await openMenu();
|
||||
await act(async () => {
|
||||
findButton(renderer!, '粘贴为新增行 (1)').props.onClick({
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(messageApi.success).toHaveBeenCalledWith('已粘贴 1 行为新增行,请检查后提交事务');
|
||||
expect(testRenderState.latestTableProps.dataSource).toHaveLength(2);
|
||||
expect(testRenderState.latestTableProps.dataSource[1][GONAVI_ROW_KEY]).toContain('paste-');
|
||||
renderer!.unmount();
|
||||
});
|
||||
|
||||
it('switches the v2 footer field tab into the main fields view', async () => {
|
||||
storeState.appearance.uiVersion = 'v2';
|
||||
|
||||
|
||||
@@ -160,6 +160,11 @@ describe('DataGrid layout', () => {
|
||||
expect(pageFindSource).toContain("textAlign: 'left'");
|
||||
expect(dataGridSource).toContain("const normalizedPageFindText = useMemo(() => normalizeDataGridFindQuery(pageFindText), [pageFindText]);");
|
||||
expect(dataGridSource).not.toContain("const normalizedPageFindText = useMemo(() => normalizeDataGridFindQuery(deferredPageFindText), [deferredPageFindText]);");
|
||||
expect(dataGridSource).toContain("if (event.key === 'Escape')");
|
||||
expect(dataGridSource).toContain('if (activeSelection.size === 0) {');
|
||||
expect(dataGridSource).toContain('closeCellEditMode();');
|
||||
expect(dataGridSource).toContain('resetCellSelection();');
|
||||
expect(dataGridSource).toContain("tagName === 'input' || tagName === 'textarea' || activeElement?.isContentEditable");
|
||||
expect(paginationSource).toContain("padding: 0");
|
||||
expect(paginationSource).toContain("justifyContent: 'flex-start'");
|
||||
});
|
||||
@@ -357,7 +362,7 @@ describe('DataGrid layout', () => {
|
||||
expect(queryMarkup).not.toContain('data-grid-ddl-action="true"');
|
||||
});
|
||||
|
||||
it('renders row copy and paste actions in editable table toolbar', () => {
|
||||
it('keeps row copy and paste as context menu actions instead of toolbar buttons', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
data={[
|
||||
@@ -374,10 +379,8 @@ describe('DataGrid layout', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-grid-copy-row-action="true"');
|
||||
expect(markup).toContain('data-grid-paste-row-action="true"');
|
||||
expect(markup).toContain('复制行');
|
||||
expect(markup).toContain('粘贴行');
|
||||
expect(markup).not.toContain('data-grid-copy-row-action="true"');
|
||||
expect(markup).not.toContain('data-grid-paste-row-action="true"');
|
||||
});
|
||||
|
||||
it('renders a clickable copy action for aggregate query results', () => {
|
||||
@@ -398,6 +401,33 @@ describe('DataGrid layout', () => {
|
||||
expect(markup).toContain('data-grid-query-copy-action="true"');
|
||||
expect(markup).not.toMatch(/data-grid-query-copy-action="true"[^>]*disabled/);
|
||||
expect(markup).toContain('复制');
|
||||
expect(markup.match(/data-grid-query-copy-action="true"/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps query-result export scopes explicit and repositions v2 context menus after measuring', () => {
|
||||
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain("type QueryResultExportScope = 'selected' | 'page' | 'all';");
|
||||
expect(source).toContain("title: '导出查询结果'");
|
||||
expect(source).toContain('data-query-result-export-scope="true"');
|
||||
expect(source).toContain('选中导出');
|
||||
expect(source).toContain('当前页导出');
|
||||
expect(source).toContain('全部导出');
|
||||
expect(source).toContain('const queryResultCurrentPageRows = useMemo(() => {');
|
||||
expect(source).toContain('const resolveContextMenuPosition = useCallback((x: number, y: number, estimatedWidth: number, estimatedHeight: number) => {');
|
||||
expect(source).toContain('const rect = element.getBoundingClientRect();');
|
||||
expect(source).toContain('ref={cellContextMenuPortalRef}');
|
||||
});
|
||||
|
||||
it('keeps inline cell editors stretched to the full cell width', () => {
|
||||
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain('const INLINE_EDIT_FORM_ITEM_STYLE: React.CSSProperties = { margin: 0, width: \'100%\', minWidth: 0 };');
|
||||
expect(source).toContain('className="data-grid-inline-editor-form-item"');
|
||||
expect(source).toContain('className="data-grid-inline-editor-input"');
|
||||
expect(source).toContain('style={{ width: \'100%\', ...inputCellPadding }}');
|
||||
expect(source).toContain('.${gridId} .data-grid-inline-editor-form-item .ant-form-item-control-input-content');
|
||||
expect(source).toContain('.${gridId} .data-grid-inline-editor-input');
|
||||
});
|
||||
|
||||
it('renders a quick WHERE condition editor when table filters are visible', () => {
|
||||
|
||||
@@ -955,7 +955,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
|
||||
if (editable) {
|
||||
childNode = editing ? (
|
||||
<Form.Item style={{ margin: 0 }} name={getCellFieldName(record, dataIndex)}>
|
||||
<Form.Item className="data-grid-inline-editor-form-item" style={INLINE_EDIT_FORM_ITEM_STYLE} name={getCellFieldName(record, dataIndex)}>
|
||||
{isDateTimeField ? (
|
||||
pickerType === 'time' ? (
|
||||
<TimePicker
|
||||
@@ -1014,7 +1014,8 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
style={inputCellPadding}
|
||||
className="data-grid-inline-editor-input"
|
||||
style={{ width: '100%', ...inputCellPadding }}
|
||||
onPressEnter={() => { void save(); }}
|
||||
onBlur={() => { void save(); }}
|
||||
onFocus={(e) => {
|
||||
@@ -1229,6 +1230,7 @@ type GridFilterCondition = FilterCondition & {
|
||||
|
||||
type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er';
|
||||
type DdlViewLayoutMode = 'bottom' | 'side';
|
||||
type QueryResultExportScope = 'selected' | 'page' | 'all';
|
||||
type VirtualEditingCellState = {
|
||||
rowKey: string;
|
||||
dataIndex: string;
|
||||
@@ -1457,6 +1459,7 @@ const VIRTUAL_CELL_TEXT_STYLE: React.CSSProperties = {
|
||||
width: '100%',
|
||||
};
|
||||
const READONLY_CELL_WRAP_STYLE: React.CSSProperties = { minHeight: 20, display: 'flex', alignItems: 'center', width: '100%', minWidth: 0 };
|
||||
const INLINE_EDIT_FORM_ITEM_STYLE: React.CSSProperties = { margin: 0, width: '100%', minWidth: 0 };
|
||||
const VIRTUAL_EDITING_CELL_STYLE: React.CSSProperties = {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
@@ -1795,10 +1798,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
// 布局常量(纯数字/字符串,无需 memoize)
|
||||
const panelRadius = 10;
|
||||
const panelOuterGap = 6;
|
||||
const panelPaddingY = 10;
|
||||
const panelOuterGap = isQueryResultExport ? 2 : 6;
|
||||
const panelPaddingY = isQueryResultExport ? 8 : 10;
|
||||
const panelPaddingX = 12;
|
||||
const toolbarBottomPadding = 6;
|
||||
const toolbarBottomPadding = isQueryResultExport ? 4 : 6;
|
||||
const filterTopPadding = 2;
|
||||
const floatingScrollbarGap = 8;
|
||||
const floatingScrollbarBottomOffset = 0;
|
||||
@@ -1894,6 +1897,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
dataIndex: '',
|
||||
title: '',
|
||||
});
|
||||
const cellContextMenuPortalRef = useRef<HTMLDivElement | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -2000,25 +2004,26 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}, [cellContextMenu.visible]);
|
||||
|
||||
const resolveContextMenuPosition = useCallback((x: number, y: number, estimatedWidth: number, estimatedHeight: number) => {
|
||||
const viewportH = window.innerHeight;
|
||||
const viewportW = window.innerWidth;
|
||||
const safeGap = 8;
|
||||
let nextY = y;
|
||||
let nextX = x;
|
||||
if (nextY + estimatedHeight > viewportH - safeGap) {
|
||||
nextY = Math.max(safeGap, viewportH - estimatedHeight - safeGap);
|
||||
}
|
||||
if (nextX + estimatedWidth > viewportW - safeGap) {
|
||||
nextX = Math.max(safeGap, viewportW - estimatedWidth - safeGap);
|
||||
}
|
||||
return { x: nextX, y: nextY };
|
||||
}, []);
|
||||
|
||||
const showCellContextMenu = useCallback((e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const titleText = typeof (title as any) === 'string' ? (title as string) : (typeof (title as any) === 'number' ? String(title) : String(dataIndex));
|
||||
// 预估菜单尺寸(菜单项数 × 行高 + 分隔线 + padding)
|
||||
const estimatedMenuHeight = 320;
|
||||
const estimatedMenuWidth = 200;
|
||||
const viewportH = window.innerHeight;
|
||||
const viewportW = window.innerWidth;
|
||||
let menuY = e.clientY;
|
||||
let menuX = e.clientX;
|
||||
// 底部空间不足时向上偏移
|
||||
if (menuY + estimatedMenuHeight > viewportH) {
|
||||
menuY = Math.max(4, viewportH - estimatedMenuHeight);
|
||||
}
|
||||
// 右侧空间不足时向左偏移
|
||||
if (menuX + estimatedMenuWidth > viewportW) {
|
||||
menuX = Math.max(4, viewportW - estimatedMenuWidth);
|
||||
}
|
||||
const { x: menuX, y: menuY } = resolveContextMenuPosition(e.clientX, e.clientY, 264, 420);
|
||||
setCellContextMenu({
|
||||
visible: true,
|
||||
x: menuX,
|
||||
@@ -2028,23 +2033,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
dataIndex,
|
||||
title: titleText,
|
||||
});
|
||||
}, []);
|
||||
}, [resolveContextMenuPosition]);
|
||||
|
||||
const showColumnHeaderContextMenu = useCallback((e: React.MouseEvent, columnName: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const estimatedMenuHeight = 292;
|
||||
const estimatedMenuWidth = 264;
|
||||
const viewportH = window.innerHeight;
|
||||
const viewportW = window.innerWidth;
|
||||
let menuY = e.clientY;
|
||||
let menuX = e.clientX;
|
||||
if (menuY + estimatedMenuHeight > viewportH) {
|
||||
menuY = Math.max(4, viewportH - estimatedMenuHeight);
|
||||
}
|
||||
if (menuX + estimatedMenuWidth > viewportW) {
|
||||
menuX = Math.max(4, viewportW - estimatedMenuWidth);
|
||||
}
|
||||
const { x: menuX, y: menuY } = resolveContextMenuPosition(e.clientX, e.clientY, 264, 360);
|
||||
setCellContextMenu({
|
||||
visible: true,
|
||||
x: menuX,
|
||||
@@ -2054,7 +2048,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
dataIndex: columnName,
|
||||
title: columnName,
|
||||
});
|
||||
}, []);
|
||||
}, [resolveContextMenuPosition]);
|
||||
|
||||
// Helper to export specific data
|
||||
const exportData = async (rows: any[], format: string) => {
|
||||
@@ -2612,6 +2606,19 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
.${gridId} .editable-cell-value-wrap > * {
|
||||
min-width: 0;
|
||||
}
|
||||
.${gridId} .data-grid-inline-editor-form-item,
|
||||
.${gridId} .data-grid-inline-editor-form-item .ant-form-item-row,
|
||||
.${gridId} .data-grid-inline-editor-form-item .ant-form-item-control,
|
||||
.${gridId} .data-grid-inline-editor-form-item .ant-form-item-control-input,
|
||||
.${gridId} .data-grid-inline-editor-form-item .ant-form-item-control-input-content {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.${gridId} .data-grid-inline-editor-input,
|
||||
.${gridId} .data-grid-inline-editor-form-item .ant-picker {
|
||||
width: 100% !important;
|
||||
min-width: 0;
|
||||
}
|
||||
.${gridId} .ant-table-tbody-virtual-holder .editable-cell-value-wrap {
|
||||
content-visibility: ${useVirtualEditableVisibilityHints ? 'auto' : 'visible'};
|
||||
contain-intrinsic-size: ${useVirtualEditableVisibilityHints ? '24px 160px' : 'auto'};
|
||||
@@ -3160,6 +3167,26 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTableSurfaceActive || !isV2Ui || !cellContextMenu.visible) return;
|
||||
const portal = cellContextMenuPortalRef.current;
|
||||
if (!portal) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const element = cellContextMenuPortalRef.current;
|
||||
if (!element) return;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const next = resolveContextMenuPosition(cellContextMenu.x, cellContextMenu.y, rect.width, rect.height);
|
||||
if (next.x !== cellContextMenu.x || next.y !== cellContextMenu.y) {
|
||||
setCellContextMenu((prev) => {
|
||||
if (!prev.visible) return prev;
|
||||
if (prev.x === next.x && prev.y === next.y) return prev;
|
||||
return { ...prev, x: next.x, y: next.y };
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [cellContextMenu.visible, cellContextMenu.x, cellContextMenu.y, isTableSurfaceActive, isV2Ui, resolveContextMenuPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
cellEditModeRef.current = cellEditMode;
|
||||
}, [cellEditMode]);
|
||||
@@ -4893,7 +4920,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
className="data-grid-virtual-inline-editing"
|
||||
onContextMenu={(e) => handleVirtualCellContextMenu(e, record, dataIndex)}
|
||||
>
|
||||
<Form.Item style={{ margin: 0, width: '100%' }} name={getCellFieldName(record, dataIndex)}>
|
||||
<Form.Item className="data-grid-inline-editor-form-item" style={INLINE_EDIT_FORM_ITEM_STYLE} name={getCellFieldName(record, dataIndex)}>
|
||||
{isDateTimeField ? (
|
||||
pickerType === 'time' ? (
|
||||
<TimePicker
|
||||
@@ -4956,6 +4983,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
) : (
|
||||
<Input
|
||||
ref={virtualInlineInputRef}
|
||||
className="data-grid-inline-editor-input"
|
||||
style={{ width: '100%', ...inputCellPadding }}
|
||||
onPressEnter={() => { void saveVirtualInlineEditor(); }}
|
||||
onBlur={() => { void saveVirtualInlineEditor(); }}
|
||||
@@ -5004,15 +5032,14 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
setAddedRows(prev => [...prev, newRow]);
|
||||
};
|
||||
|
||||
const handleCopySelectedRowsForPaste = useCallback(() => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
const copyRowsForPaste = useCallback((keys: React.Key[]) => {
|
||||
if (keys.length === 0) {
|
||||
void message.info('请先选择要复制的行');
|
||||
return;
|
||||
}
|
||||
|
||||
const copiedRows = buildCopiedRowsForPaste({
|
||||
rows: mergedDisplayData as Array<Record<string, any>>,
|
||||
selectedRowKeys,
|
||||
selectedRowKeys: keys,
|
||||
columnNames: displayOutputColumnNames.filter((columnName) => isWritableResultColumn(columnName, effectiveEditLocator)),
|
||||
rowKeyField: GONAVI_ROW_KEY,
|
||||
rowKeyToString: rowKeyStr,
|
||||
@@ -5024,7 +5051,11 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
setCopiedRowsForPaste(copiedRows);
|
||||
void message.success(`已复制 ${copiedRows.length} 行,可粘贴为新增行`);
|
||||
}, [selectedRowKeys, mergedDisplayData, displayOutputColumnNames, rowKeyStr, effectiveEditLocator]);
|
||||
}, [mergedDisplayData, displayOutputColumnNames, rowKeyStr, effectiveEditLocator]);
|
||||
|
||||
const handleCopySelectedRowsForPaste = useCallback(() => {
|
||||
copyRowsForPaste(selectedRowKeys);
|
||||
}, [copyRowsForPaste, selectedRowKeys]);
|
||||
|
||||
const handlePasteCopiedRowsAsNew = useCallback(() => {
|
||||
if (copiedRowsForPaste.length === 0) {
|
||||
@@ -5403,15 +5434,26 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
if (!cellEditMode) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const isCopy = (event.ctrlKey || event.metaKey) && !event.altKey && String(event.key || '').toLowerCase() === 'c';
|
||||
if (!isCopy) return;
|
||||
|
||||
const activeElement = document.activeElement as HTMLElement | null;
|
||||
const tagName = String(activeElement?.tagName || '').toLowerCase();
|
||||
if (tagName === 'input' || tagName === 'textarea' || activeElement?.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
|
||||
event.preventDefault();
|
||||
if (activeSelection.size === 0) {
|
||||
closeCellEditMode();
|
||||
return;
|
||||
}
|
||||
resetCellSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const isCopy = (event.ctrlKey || event.metaKey) && !event.altKey && String(event.key || '').toLowerCase() === 'c';
|
||||
if (!isCopy) return;
|
||||
|
||||
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
|
||||
if (activeSelection.size === 0) return;
|
||||
|
||||
@@ -5421,7 +5463,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [cellEditMode, selectedCells, handleCopySelectedCellsToClipboard]);
|
||||
}, [cellEditMode, selectedCells, handleCopySelectedCellsToClipboard, resetCellSelection, closeCellEditMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cellEditMode) return;
|
||||
@@ -5690,13 +5732,77 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return sql;
|
||||
}, [tableName, filterConditions, quickWhereCondition, sortInfo, pkColumns, displayOutputColumnNames]);
|
||||
|
||||
// Context Menu Export
|
||||
const handleExportSelected = useCallback(async (format: string, record: any) => {
|
||||
const records = getTargets(record);
|
||||
if (isQueryResultExport) {
|
||||
await exportData(records, format);
|
||||
const queryResultCurrentPageRows = useMemo(() => {
|
||||
if (!pagination) {
|
||||
return mergedDisplayData;
|
||||
}
|
||||
const offset = Math.max(0, (pagination.current - 1) * pagination.pageSize);
|
||||
return mergedDisplayData.slice(offset, offset + pagination.pageSize);
|
||||
}, [mergedDisplayData, pagination]);
|
||||
|
||||
const exportQueryResultRows = useCallback(async (format: string, scope: QueryResultExportScope) => {
|
||||
if (scope === 'selected') {
|
||||
const selectedKeySet = new Set(selectedRowKeys.map((key) => rowKeyStr(key)));
|
||||
const rows = mergedDisplayData.filter((row) => {
|
||||
const key = row?.[GONAVI_ROW_KEY];
|
||||
return key !== undefined && key !== null && selectedKeySet.has(rowKeyStr(key));
|
||||
});
|
||||
if (rows.length === 0) {
|
||||
void message.info('当前未选中任何行');
|
||||
return;
|
||||
}
|
||||
await exportData(rows, format);
|
||||
return;
|
||||
}
|
||||
if (scope === 'page') {
|
||||
await exportData(queryResultCurrentPageRows, format);
|
||||
return;
|
||||
}
|
||||
await exportData(mergedDisplayData, format);
|
||||
}, [exportData, mergedDisplayData, queryResultCurrentPageRows, rowKeyStr, selectedRowKeys]);
|
||||
|
||||
const openQueryResultExportScopeModal = useCallback((format: string) => {
|
||||
let instance: { destroy: () => void } | null = null;
|
||||
const selectedCount = selectedRowKeys.length;
|
||||
const runExport = async (scope: QueryResultExportScope) => {
|
||||
instance?.destroy();
|
||||
await exportQueryResultRows(format, scope);
|
||||
};
|
||||
instance = modal.info({
|
||||
title: '导出查询结果',
|
||||
content: (
|
||||
<div data-query-result-export-scope="true">
|
||||
<p style={{ marginBottom: 12 }}>请选择导出范围:</p>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => instance?.destroy()}>取消</Button>
|
||||
<Button
|
||||
disabled={selectedCount <= 0}
|
||||
onClick={() => { void runExport('selected'); }}
|
||||
>
|
||||
选中导出{selectedCount > 0 ? ` (${selectedCount}条)` : ''}
|
||||
</Button>
|
||||
<Button onClick={() => { void runExport('page'); }}>
|
||||
当前页导出 ({queryResultCurrentPageRows.length}条)
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => { void runExport('all'); }}>
|
||||
全部导出 ({mergedDisplayData.length}条)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
icon: <ExportOutlined />,
|
||||
okButtonProps: { style: { display: 'none' } },
|
||||
maskClosable: true,
|
||||
});
|
||||
}, [exportQueryResultRows, mergedDisplayData.length, modal, queryResultCurrentPageRows.length, selectedRowKeys.length]);
|
||||
|
||||
// Context Menu Export
|
||||
const handleExportSelected = useCallback(async (format: string, record: any) => {
|
||||
if (isQueryResultExport) {
|
||||
await exportData(getContextMenuTargetRows(record), format);
|
||||
return;
|
||||
}
|
||||
const records = getTargets(record);
|
||||
if (!connectionId || !tableName) {
|
||||
await exportData(records, format);
|
||||
return;
|
||||
@@ -5743,6 +5849,22 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
if (record) handleCopyRowData(record);
|
||||
closeMenu();
|
||||
return;
|
||||
case 'copy-row-for-paste':
|
||||
if (record) {
|
||||
const rowKey = record?.[GONAVI_ROW_KEY];
|
||||
if (rowKey === undefined || rowKey === null) {
|
||||
void message.info('未识别到可复制的行');
|
||||
} else {
|
||||
setSelectedRowKeys([rowKey]);
|
||||
copyRowsForPaste([rowKey]);
|
||||
}
|
||||
}
|
||||
closeMenu();
|
||||
return;
|
||||
case 'paste-row-as-new':
|
||||
handlePasteCopiedRowsAsNew();
|
||||
closeMenu();
|
||||
return;
|
||||
case 'copy-column-data':
|
||||
handleCopyColumnData(cellContextMenu.dataIndex);
|
||||
closeMenu();
|
||||
@@ -5831,8 +5953,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
// Export
|
||||
const handleExport = async (format: string) => {
|
||||
if (isQueryResultExport) {
|
||||
openQueryResultExportScopeModal(format);
|
||||
return;
|
||||
}
|
||||
if (!connectionId) return;
|
||||
|
||||
|
||||
// 1. Export Selected
|
||||
if (selectedRowKeys.length > 0) {
|
||||
const selectedRows = displayData.filter(d => selectedRowKeys.includes(d?.[GONAVI_ROW_KEY]));
|
||||
@@ -5840,12 +5966,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// 查询结果页导出统一按当前结果集(已加载数据)导出,避免再次执行原 SQL 造成大数据导出或长时间阻塞。
|
||||
if (isQueryResultExport) {
|
||||
await exportData(mergedDisplayData, format);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Prompt for Current vs All
|
||||
// Using a custom modal content with buttons to handle 3 states
|
||||
let instance: any;
|
||||
@@ -5944,7 +6064,13 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
if (onReload) onReload();
|
||||
};
|
||||
|
||||
const exportMenu: MenuProps['items'] = hasFilteredExportSql ? [
|
||||
const exportMenu: MenuProps['items'] = isQueryResultExport ? [
|
||||
{ key: 'query-csv', label: 'CSV', onClick: () => handleExport('csv') },
|
||||
{ key: 'query-xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') },
|
||||
{ key: 'query-json', label: 'JSON', onClick: () => handleExport('json') },
|
||||
{ key: 'query-md', label: 'Markdown', onClick: () => handleExport('md') },
|
||||
{ key: 'query-html', label: 'HTML', onClick: () => handleExport('html') },
|
||||
] : hasFilteredExportSql ? [
|
||||
{ type: 'group', label: '筛选结果', children: [
|
||||
{ key: 'filtered-csv', label: 'CSV', onClick: () => handleExportFilteredAll('csv') },
|
||||
{ key: 'filtered-xlsx', label: 'Excel (XLSX)', onClick: () => handleExportFilteredAll('xlsx') },
|
||||
@@ -7173,7 +7299,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
onToggleFilter={onToggleFilter}
|
||||
canModifyData={canModifyData}
|
||||
selectedRowKeysLength={selectedRowKeys.length}
|
||||
copiedRowsForPasteLength={copiedRowsForPaste.length}
|
||||
allSelectedAreDeleted={allSelectedAreDeleted}
|
||||
cellEditMode={cellEditMode}
|
||||
selectedCellsSize={selectedCells.size}
|
||||
@@ -7214,8 +7339,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
onRefresh={handleRefreshGrid}
|
||||
onToggleFilterClick={handleToggleFilterWithDefault}
|
||||
onAddRow={handleAddRow}
|
||||
onCopySelectedRowsForPaste={handleCopySelectedRowsForPaste}
|
||||
onPasteCopiedRowsAsNew={handlePasteCopiedRowsAsNew}
|
||||
onUndoDeleteSelected={handleUndoDeleteSelected}
|
||||
onDeleteSelected={handleDeleteSelected}
|
||||
onToggleCellEditMode={handleToggleCellEditMode}
|
||||
@@ -7415,6 +7538,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
{isTableSurfaceActive && isV2Ui && cellContextMenu.visible && createPortal(
|
||||
<div
|
||||
ref={cellContextMenuPortalRef}
|
||||
className="gn-v2-table-context-menu-portal"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
@@ -7431,6 +7555,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return (
|
||||
<V2ColumnHeaderContextMenuView
|
||||
fieldName={fieldName}
|
||||
shortcutPlatform={activeShortcutPlatform}
|
||||
columnType={meta?.type}
|
||||
columnComment={meta?.comment}
|
||||
sortOrder={(activeSort?.order === 'ascend' || activeSort?.order === 'descend') ? activeSort.order : null}
|
||||
@@ -7442,10 +7567,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
})() : (
|
||||
<V2CellContextMenuView
|
||||
fieldName={resolveContextMenuFieldName(cellContextMenu.dataIndex, cellContextMenu.title)}
|
||||
shortcutPlatform={activeShortcutPlatform}
|
||||
tableName={tableName}
|
||||
rowLabel={cellContextMenu.record?.[GONAVI_ROW_KEY] === undefined ? undefined : `row ${String(cellContextMenu.record?.[GONAVI_ROW_KEY])}`}
|
||||
selectedRowCount={selectedRowKeys.length}
|
||||
canModifyData={canModifyData}
|
||||
copiedRowCount={copiedRowsForPaste.length}
|
||||
canPasteCopiedColumns={!!copiedCellPatch}
|
||||
supportsCopyInsert={supportsCopyInsert}
|
||||
onAction={handleV2CellContextMenuAction}
|
||||
@@ -7461,11 +7588,25 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
bgContextMenu={bgContextMenu}
|
||||
cellContextMenu={cellContextMenu}
|
||||
canModifyData={canModifyData}
|
||||
copiedRowsForPasteLength={copiedRowsForPaste.length}
|
||||
selectedRowKeysLength={selectedRowKeys.length}
|
||||
copiedCellPatchAvailable={!!copiedCellPatch}
|
||||
supportsCopyInsert={supportsCopyInsert}
|
||||
onClose={() => setCellContextMenu(prev => ({ ...prev, visible: false }))}
|
||||
onCopyFieldName={handleCopyContextMenuFieldName}
|
||||
onCopyRowData={() => {
|
||||
if (cellContextMenu.record) handleCopyRowData(cellContextMenu.record);
|
||||
}}
|
||||
onCopyRowForPaste={() => {
|
||||
const rowKey = cellContextMenu.record?.[GONAVI_ROW_KEY];
|
||||
if (rowKey === undefined || rowKey === null) {
|
||||
void message.info('未识别到可复制的行');
|
||||
return;
|
||||
}
|
||||
setSelectedRowKeys([rowKey]);
|
||||
copyRowsForPaste([rowKey]);
|
||||
}}
|
||||
onPasteCopiedRowsAsNew={handlePasteCopiedRowsAsNew}
|
||||
onSetNull={handleCellSetNull}
|
||||
onEditRow={handleOpenContextMenuRowEditor}
|
||||
onFillToSelected={() => {
|
||||
|
||||
@@ -16,11 +16,15 @@ interface DataGridLegacyCellContextMenuProps {
|
||||
bgContextMenu: string;
|
||||
cellContextMenu: CellContextMenuState;
|
||||
canModifyData: boolean;
|
||||
copiedRowsForPasteLength: number;
|
||||
selectedRowKeysLength: number;
|
||||
copiedCellPatchAvailable: boolean;
|
||||
supportsCopyInsert: boolean;
|
||||
onClose: () => void;
|
||||
onCopyFieldName: () => void;
|
||||
onCopyRowData: () => void;
|
||||
onCopyRowForPaste: () => void;
|
||||
onPasteCopiedRowsAsNew: () => void;
|
||||
onSetNull: () => void;
|
||||
onEditRow: () => void;
|
||||
onFillToSelected: () => void;
|
||||
@@ -55,11 +59,15 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
|
||||
bgContextMenu,
|
||||
cellContextMenu,
|
||||
canModifyData,
|
||||
copiedRowsForPasteLength,
|
||||
selectedRowKeysLength,
|
||||
copiedCellPatchAvailable,
|
||||
supportsCopyInsert,
|
||||
onClose,
|
||||
onCopyFieldName,
|
||||
onCopyRowData,
|
||||
onCopyRowForPaste,
|
||||
onPasteCopiedRowsAsNew,
|
||||
onSetNull,
|
||||
onEditRow,
|
||||
onFillToSelected,
|
||||
@@ -81,6 +89,7 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
|
||||
|
||||
const hoverBg = darkMode ? '#303030' : '#f5f5f5';
|
||||
const canFillRows = selectedRowKeysLength > 0;
|
||||
const canPasteRows = copiedRowsForPasteLength > 0;
|
||||
|
||||
const makeHoverHandlers = (enabled = true) => ({
|
||||
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -129,6 +138,27 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
|
||||
<EditOutlined style={{ marginRight: 8 }} />
|
||||
编辑本行
|
||||
</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyRowForPaste)}>
|
||||
<CopyOutlined style={{ marginRight: 8 }} />
|
||||
复制本行为新增行
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
...baseItemStyle,
|
||||
cursor: canPasteRows ? 'pointer' : 'not-allowed',
|
||||
opacity: canPasteRows ? 1 : 0.5,
|
||||
}}
|
||||
{...makeHoverHandlers(canPasteRows)}
|
||||
onClick={() => {
|
||||
if (canPasteRows) {
|
||||
onPasteCopiedRowsAsNew();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
|
||||
{canPasteRows ? `粘贴为新增行 (${copiedRowsForPasteLength})` : '粘贴为新增行'}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
...baseItemStyle,
|
||||
@@ -160,6 +190,10 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
|
||||
<div style={separatorStyle(darkMode)} />
|
||||
</>
|
||||
)}
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyRowData)}>
|
||||
<CopyOutlined style={{ marginRight: 8 }} />
|
||||
复制行数据
|
||||
</div>
|
||||
{supportsCopyInsert && (
|
||||
<>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyInsert)}>复制为 INSERT</div>
|
||||
|
||||
@@ -59,7 +59,6 @@ export interface DataGridToolbarFrameProps {
|
||||
onToggleFilter?: () => void;
|
||||
canModifyData: boolean;
|
||||
selectedRowKeysLength: number;
|
||||
copiedRowsForPasteLength: number;
|
||||
allSelectedAreDeleted: boolean;
|
||||
cellEditMode: boolean;
|
||||
selectedCellsSize: number;
|
||||
@@ -95,8 +94,6 @@ export interface DataGridToolbarFrameProps {
|
||||
onRefresh: () => void;
|
||||
onToggleFilterClick: () => void;
|
||||
onAddRow: () => void;
|
||||
onCopySelectedRowsForPaste: () => void;
|
||||
onPasteCopiedRowsAsNew: () => void;
|
||||
onUndoDeleteSelected: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
onToggleCellEditMode: () => void;
|
||||
@@ -155,7 +152,6 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
onToggleFilter,
|
||||
canModifyData,
|
||||
selectedRowKeysLength,
|
||||
copiedRowsForPasteLength,
|
||||
allSelectedAreDeleted,
|
||||
cellEditMode,
|
||||
selectedCellsSize,
|
||||
@@ -191,8 +187,6 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
onRefresh,
|
||||
onToggleFilterClick,
|
||||
onAddRow,
|
||||
onCopySelectedRowsForPaste,
|
||||
onPasteCopiedRowsAsNew,
|
||||
onUndoDeleteSelected,
|
||||
onDeleteSelected,
|
||||
onToggleCellEditMode,
|
||||
@@ -301,22 +295,6 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
<>
|
||||
{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>
|
||||
) : (
|
||||
@@ -394,16 +372,15 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
{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} />
|
||||
<Button
|
||||
data-grid-query-copy-action="true"
|
||||
icon={<CopyOutlined />}
|
||||
disabled={!canCopyQueryResult}
|
||||
onClick={onCopyQueryResultCsv}
|
||||
>
|
||||
复制 <DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user