mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-28 17:31:32 +08:00
♻️ refactor(DataGrid): 拆分数据网格视图与交互状态
- 拆分 DataGrid 的筛选、DDL 视图、模态编辑和预览面板状态 - 抽离表头信息、分页栏、视图切换、辅助操作和旧版单元格右键菜单组件 - 优化虚拟单元格渲染判定与横向滚轮意图识别,减少滚动和编辑阶段的无效重绘 - 新增 DataGrid 性能复现页并补齐布局、DDL、列标题与滚动相关测试
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
104
frontend/src/components/DataGridColumnInfoPopoverContent.tsx
Normal file
104
frontend/src/components/DataGridColumnInfoPopoverContent.tsx
Normal 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;
|
||||
70
frontend/src/components/DataGridColumnTitle.test.tsx
Normal file
70
frontend/src/components/DataGridColumnTitle.test.tsx
Normal 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"');
|
||||
});
|
||||
});
|
||||
168
frontend/src/components/DataGridColumnTitle.tsx
Normal file
168
frontend/src/components/DataGridColumnTitle.tsx
Normal 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;
|
||||
183
frontend/src/components/DataGridLegacyCellContextMenu.tsx
Normal file
183
frontend/src/components/DataGridLegacyCellContextMenu.tsx
Normal 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;
|
||||
313
frontend/src/components/DataGridModals.tsx
Normal file
313
frontend/src/components/DataGridModals.tsx
Normal 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;
|
||||
79
frontend/src/components/DataGridPageFind.tsx
Normal file
79
frontend/src/components/DataGridPageFind.tsx
Normal 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;
|
||||
126
frontend/src/components/DataGridPaginationBar.tsx
Normal file
126
frontend/src/components/DataGridPaginationBar.tsx
Normal 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;
|
||||
131
frontend/src/components/DataGridPreviewPanel.tsx
Normal file
131
frontend/src/components/DataGridPreviewPanel.tsx
Normal 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;
|
||||
111
frontend/src/components/DataGridRecordViews.tsx
Normal file
111
frontend/src/components/DataGridRecordViews.tsx
Normal 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>
|
||||
);
|
||||
38
frontend/src/components/DataGridResultViewSwitcher.tsx
Normal file
38
frontend/src/components/DataGridResultViewSwitcher.tsx
Normal 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;
|
||||
152
frontend/src/components/DataGridSecondaryActions.tsx
Normal file
152
frontend/src/components/DataGridSecondaryActions.tsx
Normal 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;
|
||||
731
frontend/src/components/DataGridToolbarFrame.tsx
Normal file
731
frontend/src/components/DataGridToolbarFrame.tsx
Normal 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;
|
||||
127
frontend/src/components/DataGridV2DdlWorkspace.tsx
Normal file
127
frontend/src/components/DataGridV2DdlWorkspace.tsx
Normal 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>
|
||||
);
|
||||
92
frontend/src/components/DataGridV2MetadataViews.tsx
Normal file
92
frontend/src/components/DataGridV2MetadataViews.tsx
Normal 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>
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
194
frontend/src/components/useDataGridDdlView.ts
Normal file
194
frontend/src/components/useDataGridDdlView.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
379
frontend/src/components/useDataGridFilters.tsx
Normal file
379
frontend/src/components/useDataGridFilters.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
183
frontend/src/components/useDataGridModalEditors.ts
Normal file
183
frontend/src/components/useDataGridModalEditors.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
100
frontend/src/components/useDataGridPreviewPanel.ts
Normal file
100
frontend/src/components/useDataGridPreviewPanel.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
157
frontend/src/dev/PerfDataGridHarness.tsx
Normal file
157
frontend/src/dev/PerfDataGridHarness.tsx
Normal 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;
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user