diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx
index 5831d98..967371b 100644
--- a/frontend/src/components/DataGrid.ddl.test.tsx
+++ b/frontend/src/components/DataGrid.ddl.test.tsx
@@ -65,6 +65,8 @@ const backendApp = vi.hoisted(() => ({
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
DBGetForeignKeys: vi.fn(),
+ DBGetTriggers: vi.fn(),
+ DBQuery: vi.fn(),
DBShowCreateTable: vi.fn(),
}));
@@ -112,6 +114,16 @@ vi.mock('./ImportPreviewModal', () => ({
default: () => null,
}));
+vi.mock('./TableDesigner', () => ({
+ default: ({ tab, embedded }: { tab: { tableName?: string; initialTab?: string }; embedded?: boolean }) => (
+
{(options || []).map((option: any) => (
@@ -255,7 +292,7 @@ vi.mock('antd', () => {
Dropdown: passthrough,
Form,
Pagination: () => null,
- Select: () => null,
+ Select: ({ children }: any) =>
{children}
,
Modal,
Checkbox: ({ checked, onChange }: any) =>
,
Segmented,
@@ -264,6 +301,11 @@ vi.mock('antd', () => {
DatePicker: () => null,
TimePicker: () => null,
AutoComplete: ({ children }: any) => <>{children}>,
+ Tabs,
+ Empty,
+ Space,
+ Tag,
+ Radio,
};
});
@@ -509,6 +551,8 @@ describe('DataGrid DDL interactions', () => {
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetForeignKeys.mockResolvedValue({ success: true, data: [] });
+ backendApp.DBGetTriggers.mockResolvedValue({ success: true, data: [] });
+ backendApp.DBQuery.mockResolvedValue({ success: true, data: [] });
backendApp.DBShowCreateTable.mockResolvedValue({ success: true, data: 'CREATE TABLE users' });
storeState.appearance.uiVersion = 'legacy';
storeState.addTab.mockReset();
@@ -557,6 +601,8 @@ describe('DataGrid DDL interactions', () => {
backendApp.DBGetColumns.mockReset();
backendApp.DBGetIndexes.mockReset();
backendApp.DBGetForeignKeys.mockReset();
+ backendApp.DBGetTriggers.mockReset();
+ backendApp.DBQuery.mockReset();
backendApp.DBShowCreateTable.mockReset();
vi.unstubAllGlobals();
});
@@ -654,6 +700,7 @@ describe('DataGrid DDL interactions', () => {
connectionId: 'conn-1',
dbName: 'main',
tableName: 'customers',
+ objectType: 'table',
});
},
);
@@ -949,8 +996,15 @@ describe('DataGrid DDL interactions', () => {
renderer!.unmount();
});
- it('switches the v2 footer field tab into the main fields view', async () => {
+ it('switches the v2 footer object tab into the embedded designer view', async () => {
storeState.appearance.uiVersion = 'v2';
+ backendApp.DBGetColumns.mockResolvedValueOnce({
+ success: true,
+ data: [
+ { name: 'id', type: 'bigint', key: 'PRI', nullable: 'NO', default: '', comment: '' },
+ { name: 'name', type: 'varchar(255)', key: '', nullable: 'YES', default: '', comment: '' },
+ ],
+ });
let renderer: ReactTestRenderer;
await act(async () => {
@@ -968,12 +1022,12 @@ describe('DataGrid DDL interactions', () => {
await waitForEffects();
await act(async () => {
- findButton(renderer!, '字段信息').props.onClick();
+ findButton(renderer!, '对象设计').props.onClick();
});
const content = textContent(renderer!.root);
- expect(content).toContain('FIELDS');
- expect(content).toContain('2 个字段');
+ expect(content).toContain('SCHEMA DESIGNER');
+ expect(content).toContain('字段');
expect(content).toContain('id');
expect(content).toContain('name');
});
@@ -997,9 +1051,9 @@ describe('DataGrid DDL interactions', () => {
await waitForEffects();
await act(async () => {
- findButton(renderer!, '字段信息').props.onClick();
+ findButton(renderer!, '对象设计').props.onClick();
});
- expect(textContent(renderer!.root)).toContain('FIELDS');
+ expect(textContent(renderer!.root)).toContain('SCHEMA DESIGNER');
storeState.appearance.uiVersion = 'legacy';
await act(async () => {
@@ -1017,13 +1071,54 @@ describe('DataGrid DDL interactions', () => {
await waitForEffects();
const content = textContent(renderer!.root);
- expect(content).not.toContain('FIELDS');
+ expect(content).not.toContain('SCHEMA DESIGNER');
expect(content).not.toContain('gn-v2-data-grid-fields-view');
expect(content).toContain('数据预览');
expect(content).toContain('结果视图');
expect(content).toContain('字段信息');
});
+ it('keeps the v2 fields tab as read-only field info for views', async () => {
+ storeState.appearance.uiVersion = 'v2';
+ backendApp.DBGetColumns.mockResolvedValueOnce({
+ success: true,
+ data: [
+ { name: 'id', type: 'bigint', key: '', nullable: 'NO', default: '', comment: '' },
+ { name: 'name', type: 'varchar(255)', key: '', nullable: 'YES', default: '', comment: '' },
+ ],
+ });
+
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create(
+
,
+ );
+ });
+ await waitForEffects();
+
+ expect(textContent(renderer!.root)).toContain('字段信息');
+ expect(textContent(renderer!.root)).not.toContain('对象设计');
+
+ await act(async () => {
+ findButton(renderer!, '字段信息').props.onClick();
+ });
+
+ const content = textContent(renderer!.root);
+ expect(content).toContain('FIELDS');
+ expect(content).toContain('2 个字段');
+ expect(content).toContain('id');
+ expect(content).toContain('name');
+ expect(content).not.toContain('SCHEMA DESIGNER');
+ });
+
it('renders the v2 footer DDL view with the Monaco SQL editor', async () => {
storeState.appearance.uiVersion = 'v2';
backendApp.DBShowCreateTable.mockResolvedValueOnce({
diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx
index 1103ce0..221522d 100644
--- a/frontend/src/components/DataGrid.layout.test.tsx
+++ b/frontend/src/components/DataGrid.layout.test.tsx
@@ -79,6 +79,8 @@ describe('DataGrid layout', () => {
columnNames={['id', 'name']}
loading={false}
tableName="users"
+ dbName="main"
+ connectionId="conn-1"
readOnly
pagination={{
current: 1,
@@ -95,6 +97,7 @@ describe('DataGrid layout', () => {
expect(markup).toContain('data-grid-column-quick-find-action="true"');
expect(markup).toContain('字段显示');
expect(markup).toContain('跳列');
+ expect(markup).toContain('对象设计');
expect(markup).toContain('data-grid-page-find="true"');
expect(markup).toContain('data-grid-page-find-prev="true"');
expect(markup).toContain('data-grid-page-find-next="true"');
@@ -112,6 +115,34 @@ describe('DataGrid layout', () => {
expect(markup).toContain('当前页查找...');
});
+ it('keeps the v2 footer fields action labeled as field info for views', () => {
+ const markup = renderToStaticMarkup(
+
{}}
+ />,
+ );
+
+ expect(markup).toContain('字段信息');
+ expect(markup).not.toContain('对象设计');
+ });
+
it('hides current-page find in JSON and text record views', () => {
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
@@ -321,6 +352,7 @@ describe('DataGrid layout', () => {
expect(tableMarkup).toContain('data-grid-ddl-action="true"');
expect(tableMarkup).toContain('查看 DDL');
+ expect(tableMarkup).toContain('对象设计');
expect(tableMarkup).not.toContain('data-grid-locate-sidebar-action="true"');
const schemaTableMarkup = renderToStaticMarkup(
@@ -342,6 +374,7 @@ describe('DataGrid layout', () => {
expect(schemaTableMarkup).toContain('data-grid-ddl-action="true"');
expect(schemaTableMarkup).toContain('查看 DDL');
+ expect(schemaTableMarkup).toContain('对象设计');
expect(schemaTableMarkup).toContain('data-grid-page-find="true"');
const queryMarkup = renderToStaticMarkup(
@@ -363,6 +396,8 @@ describe('DataGrid layout', () => {
);
expect(queryMarkup).not.toContain('data-grid-ddl-action="true"');
+ expect(queryMarkup).toContain('字段信息');
+ expect(queryMarkup).not.toContain('对象设计');
});
it('keeps row copy and paste as context menu actions instead of toolbar buttons', () => {
diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx
index 08c2c05..b860102 100644
--- a/frontend/src/components/DataGrid.tsx
+++ b/frontend/src/components/DataGrid.tsx
@@ -129,6 +129,7 @@ import DataGridPreviewPanel from './DataGridPreviewPanel';
import { DataGridJsonView, DataGridTextView } from './DataGridRecordViews';
import { DataGridV2DdlSideWorkspace, DataGridV2DdlView } from './DataGridV2DdlWorkspace';
import { DataGridV2ErView, DataGridV2FieldsView } from './DataGridV2MetadataViews';
+import TableDesigner from './TableDesigner';
import { useDataGridFilters } from './useDataGridFilters';
import { useDataGridDdlView } from './useDataGridDdlView';
import { useDataGridModalEditors } from './useDataGridModalEditors';
@@ -1191,6 +1192,7 @@ interface DataGridProps {
columnNames: string[];
loading: boolean;
tableName?: string;
+ objectType?: 'table' | 'view' | 'materialized-view';
exportScope?: 'table' | 'queryResult';
resultSql?: string;
dbName?: string;
@@ -1482,7 +1484,7 @@ const VIRTUAL_EDITING_CELL_STYLE: React.CSSProperties = {
};
const DataGrid: React.FC = ({
- data, columnNames, loading, tableName, exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false,
+ data, columnNames, loading, tableName, objectType = 'table', exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false,
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition,
onApplyQuickWhereCondition,
scrollSnapshot, onScrollSnapshotChange
@@ -1720,6 +1722,7 @@ const DataGrid: React.FC = ({
const canImport = exportScope === 'table' && !!tableName;
const canExport = !!connectionId && (isQueryResultExport || !!tableName);
const canViewDdl = exportScope === 'table' && !!connectionId && !!tableName;
+ const canOpenObjectDesigner = exportScope === 'table' && objectType === 'table' && !!connectionId && !!tableName;
const filteredExportSql = useMemo(() => String(exportSqlWithFilter || '').trim(), [exportSqlWithFilter]);
const hasFilteredExportSql = exportScope === 'table' && filteredExportSql.length > 0;
@@ -2357,6 +2360,7 @@ const DataGrid: React.FC = ({
connectionId,
dbName: targetDbName,
tableName: refTableName,
+ objectType: 'table',
});
}, [addTab, connectionId, dbName, setActiveContext]);
@@ -3225,6 +3229,22 @@ const DataGrid: React.FC = ({
},
});
+ useEffect(() => {
+ const handleExternalViewModeChange = (event: Event) => {
+ const detail = (event as CustomEvent)?.detail || {};
+ if (String(detail.connectionId || '') !== String(connectionId || '')) return;
+ if (String(detail.dbName || '') !== String(dbName || '')) return;
+ if (String(detail.tableName || '') !== String(tableName || '')) return;
+ const nextMode = String(detail.viewMode || '').trim();
+ if (!nextMode) return;
+ if (!['table', 'json', 'text', 'fields', 'ddl', 'er'].includes(nextMode)) return;
+ handleViewModeChange(nextMode as GridViewMode);
+ };
+
+ window.addEventListener('gonavi:data-grid:set-view-mode', handleExternalViewModeChange as EventListener);
+ return () => window.removeEventListener('gonavi:data-grid:set-view-mode', handleExternalViewModeChange as EventListener);
+ }, [canOpenObjectDesigner, connectionId, dbName, handleViewModeChange, tableName]);
+
useEffect(() => {
if (!isTableSurfaceActive || !isV2Ui || !cellContextMenu.visible) return;
const portal = cellContextMenuPortalRef.current;
@@ -7542,14 +7562,31 @@ const DataGrid: React.FC = ({
{viewMode === 'table' ? (
renderDataTableView()
) : isV2Ui && viewMode === 'fields' ? (
-
+ canOpenObjectDesigner ? (
+
+ ) : (
+
+ )
) : isV2Ui && viewMode === 'ddl' && ddlViewLayout === 'side' ? (
= ({
= ({
isV2Ui,
canViewDdl,
+ canOpenObjectDesigner,
viewMode,
ddlLoading,
showColumnComment,
@@ -53,9 +55,11 @@ const DataGridSecondaryActions: React.FC = ({
onOpenTableDdl,
}) => {
if (isV2Ui) {
+ const fieldsActionLabel = canOpenObjectDesigner ? '对象设计' : '字段信息';
+ const fieldsActionIcon = canOpenObjectDesigner ? : ;
const viewTabItems: Array<{ key: GridViewMode; label: string; icon: React.ReactNode; disabled?: boolean }> = [
{ key: 'table', label: '数据预览', icon: },
- { key: 'fields', label: '字段信息', icon: },
+ { key: 'fields', label: fieldsActionLabel, icon: fieldsActionIcon },
{ key: 'ddl', label: '查看 DDL', icon: , disabled: !canViewDdl },
{ key: 'er', label: 'ER 图', icon: },
];
diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx
index 1a82962..6bbc59a 100644
--- a/frontend/src/components/DataViewer.tsx
+++ b/frontend/src/components/DataViewer.tsx
@@ -1095,6 +1095,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
columnNames={columnNames}
loading={loading}
tableName={tab.tableName}
+ objectType={tab.objectType || 'table'}
exportScope="table"
dbName={tab.dbName}
connectionId={tab.connectionId}
diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx
index 663f8d4..b9fa1f3 100644
--- a/frontend/src/components/QueryEditor.tsx
+++ b/frontend/src/components/QueryEditor.tsx
@@ -2726,6 +2726,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
connectionId,
dbName: targetDbName,
tableName: targetTableName,
+ objectType: 'table',
});
dispatchQueryEditorSidebarLocate({
connectionId,
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index d2a7f8b..3d90733 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -3415,6 +3415,7 @@ const Sidebar: React.FC<{
connectionId: id,
dbName,
tableName,
+ objectType: 'table',
});
return;
} else if (node.type === 'view' || node.type === 'materialized-view') {
@@ -3426,6 +3427,7 @@ const Sidebar: React.FC<{
connectionId: id,
dbName,
tableName: viewName,
+ objectType: node.type === 'materialized-view' ? 'materialized-view' : 'view',
});
return;
} else if (node.type === 'saved-query') {
diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx
index c15260f..328bf6b 100644
--- a/frontend/src/components/TableDesigner.tsx
+++ b/frontend/src/components/TableDesigner.tsx
@@ -360,7 +360,23 @@ const SortableRow = ({ children, ...props }: RowProps) => {
);
};
-const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
+const renderDesignerCellField = (content: React.ReactNode, className?: string) => (
+
+ {content}
+
+);
+
+const renderDesignerCellCheck = (content: React.ReactNode, className?: string) => (
+
+ {content}
+
+);
+
+const renderDesignerHeaderTitle = (title: string) => (
+ {title}
+);
+
+const TableDesigner: React.FC<{ tab: TabData; embedded?: boolean }> = ({ tab, embedded = false }) => {
const isNewTable = !tab.tableName;
const [columns, setColumns] = useState([]);
@@ -431,6 +447,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const [commentEditorColumnKey, setCommentEditorColumnKey] = useState('');
const [commentEditorColumnName, setCommentEditorColumnName] = useState('');
const [commentEditorValue, setCommentEditorValue] = useState('');
+ const [inlineCommentEditingKey, setInlineCommentEditingKey] = useState('');
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
@@ -439,9 +456,6 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const isV2Ui = appearance.uiVersion === 'v2';
const resizeGuideColor = darkMode ? '#f6c453' : '#1890ff';
const readOnly = !!tab.readOnly;
- const designerTableTitle = tab.tableName || newTableName || '未命名表';
- const designerDbTitle = tab.dbName || '默认库';
- const designerColumnSummary = `${columns.length} 字段`;
const panelRadius = 10;
const panelFrameColor = darkMode ? 'rgba(0, 0, 0, 0.18)' : 'rgba(0, 0, 0, 0.12)';
const panelToolbarBorder = darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.10)';
@@ -458,6 +472,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const openCommentEditor = useCallback((record: EditableColumn) => {
if (!record?._key) return;
+ setInlineCommentEditingKey('');
setCommentEditorColumnKey(record._key);
setCommentEditorColumnName(record.name || '');
setCommentEditorValue(record.comment || '');
@@ -518,6 +533,10 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
setSelectedColumnRowKeys(prev => prev.filter(key => columns.some(c => c._key === key)));
}, [columns]);
+ useEffect(() => {
+ setInlineCommentEditingKey(prev => (prev && columns.some(c => c._key === prev) ? prev : ''));
+ }, [columns]);
+
useEffect(() => {
return () => {
if (focusHighlightTimerRef.current !== null) {
@@ -552,6 +571,15 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
return true;
}, [activeKey, readOnly]);
+ const startInlineCommentEdit = useCallback((record: EditableColumn) => {
+ if (readOnly || !record?._key) return;
+ setInlineCommentEditingKey(record._key);
+ }, [readOnly]);
+
+ const finishInlineCommentEdit = useCallback(() => {
+ setInlineCommentEditingKey('');
+ }, []);
+
useEffect(() => {
const pendingKey = pendingFocusColumnKeyRef.current;
if (!pendingKey || activeKey !== 'columns') return;
@@ -578,64 +606,80 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const columnTypeOptions = resolveColumnTypeOptions(getDbType());
const initialCols = [
{
- title: '名',
+ title: renderDesignerHeaderTitle('名'),
dataIndex: 'name',
key: 'name',
width: 180,
render: (text: string, record: EditableColumn) => readOnly ? text : (
- handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
+ renderDesignerCellField(
+ handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
+ )
)
},
{
- title: '类型',
+ title: renderDesignerHeaderTitle('类型'),
dataIndex: 'type',
key: 'type',
width: 150,
render: (text: string, record: EditableColumn) => readOnly ? text : (
- handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" />
+ renderDesignerCellField(
+ handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" />,
+ 'is-compact'
+ )
)
},
{
- title: '主键',
+ title: renderDesignerHeaderTitle('主键'),
dataIndex: 'key',
key: 'key',
width: 60,
align: 'center',
render: (text: string, record: EditableColumn) => (
- handleColumnChange(record._key, 'key', e.target.checked ? 'PRI' : '')} />
+ renderDesignerCellCheck(
+ handleColumnChange(record._key, 'key', e.target.checked ? 'PRI' : '')} />,
+ 'is-left-aligned'
+ )
)
},
{
- title: '自增',
+ title: renderDesignerHeaderTitle('自增'),
dataIndex: 'isAutoIncrement',
key: 'isAutoIncrement',
width: 60,
align: 'center',
render: (val: boolean, record: EditableColumn) => (
- handleColumnChange(record._key, 'isAutoIncrement', e.target.checked)} />
+ renderDesignerCellCheck(
+ handleColumnChange(record._key, 'isAutoIncrement', e.target.checked)} />,
+ 'is-left-aligned'
+ )
)
},
{
- title: '不是 Null',
+ title: renderDesignerHeaderTitle('不是 Null'),
dataIndex: 'nullable',
key: 'nullable',
width: 80,
align: 'center',
render: (text: string, record: EditableColumn) => (
- handleColumnChange(record._key, 'nullable', e.target.checked ? 'NO' : 'YES')} />
+ renderDesignerCellCheck(
+ handleColumnChange(record._key, 'nullable', e.target.checked ? 'NO' : 'YES')} />,
+ 'is-left-aligned'
+ )
)
},
{
- title: '默认',
+ title: renderDesignerHeaderTitle('默认'),
dataIndex: 'default',
key: 'default',
width: 180, // Increased default width
render: (text: string, record: EditableColumn) => readOnly ? text : (
- handleColumnChange(record._key, 'default', val)} style={{ width: '100%' }} variant="borderless" placeholder="NULL" />
+ renderDesignerCellField(
+ handleColumnChange(record._key, 'default', val)} style={{ width: '100%' }} variant="borderless" placeholder="NULL" />
+ )
)
},
{
- title: '注释',
+ title: renderDesignerHeaderTitle('注释'),
dataIndex: 'comment',
key: 'comment',
width: 200,
@@ -644,13 +688,26 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
{text || ''}
) : (
-
-
handleColumnChange(record._key, 'comment', e.target.value)}
- onDoubleClick={() => openCommentEditor(record)}
- variant="borderless"
- />
+
+ {inlineCommentEditingKey !== record._key ? (
+
+ startInlineCommentEdit(record)}
+ >
+ {text || '\u00A0'}
+
+
+ ) : (
+
handleColumnChange(record._key, 'comment', e.target.value)}
+ onBlur={finishInlineCommentEdit}
+ onPressEnter={finishInlineCommentEdit}
+ autoFocus={inlineCommentEditingKey === record._key}
+ variant="borderless"
+ />
+ )}