Files
MyGoNavi/frontend/src/components/TabManager.tsx
Syngnat 5b31ab7435 feat(export-workbench): 新增导出工作台与进度历史
- 新增表级导出工作台标签页,统一承载导出范围、格式和 XLSX sheet 行数配置
- 结果集、表概览、侧栏和右键菜单统一接入导出工作台与带进度的导出入口
- 导出进度改为事件驱动,未知总数时展示不定进度和实时已写入行数
- 持久化每张表的导出历史并复用同一导出标签,重启后仍可查看最近任务
- 调整导出页签标题、状态胶囊和历史列表,补充工作台与状态流测试覆盖
2026-06-17 14:40:49 +08:00

1027 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Button, Dropdown, message, Modal, Tabs, Tooltip } from 'antd';
import { AppstoreOutlined, CloseOutlined, ConsoleSqlOutlined, DatabaseOutlined, PlusOutlined, RobotOutlined, SettingOutlined } from '@ant-design/icons';
import type { MenuProps, TabsProps } from 'antd';
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
import { useStore } from '../store';
import DataViewer from './DataViewer';
import QueryEditor from './QueryEditor';
import TableDesigner from './TableDesigner';
import RedisViewer from './RedisViewer';
import RedisCommandEditor from './RedisCommandEditor';
import RedisMonitor from './RedisMonitor';
import TriggerViewer from './TriggerViewer';
import DefinitionViewer from './DefinitionViewer';
import TableOverview from './TableOverview';
import TableExportWorkbench from './TableExportWorkbench';
import JVMOverview from './JVMOverview';
import JVMResourceBrowser from './JVMResourceBrowser';
import JVMAuditViewer from './JVMAuditViewer';
import JVMDiagnosticConsole from './JVMDiagnosticConsole';
import JVMMonitoringDashboard from './JVMMonitoringDashboard';
import type { TabData } from '../types';
import { t } from '../i18n';
import {
buildTabDisplayModel,
resolveConnectionHostSummary,
type TabDisplayPart,
type TabDisplayModel,
} from '../utils/tabDisplay';
import { ReadSQLFile, WriteSQLFile } from '../../wailsjs/go/app/App';
import {
getSQLFileTabPath,
hasSQLFileTabUnsavedChanges,
isSQLFileMissingErrorMessage,
isSQLFileMissingReadResult,
isSQLFileQueryTab,
normalizeSQLFileReadContent,
} from '../utils/sqlFileTabDirty';
import { clearSQLFileTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts';
const getTabKindLabel = (tab: TabData): string => {
if (tab.type === 'query') return t('tab_manager.kind_badge.query');
if (tab.type === 'table') return t('tab_manager.kind_badge.table');
if (tab.type === 'design') return t('tab_manager.kind_badge.design');
if (tab.type === 'table-overview') return t('tab_manager.kind_badge.table_overview');
if (tab.type === 'table-export') return t('tab_manager.kind_badge.table_export');
if (tab.type.startsWith('redis')) return t('tab_manager.kind_badge.redis');
if (tab.type.startsWith('jvm')) return t('tab_manager.kind_badge.jvm');
if (tab.type === 'trigger') return t('tab_manager.kind_badge.trigger');
if (tab.type === 'view-def') {
return tab.viewKind === 'materialized'
? t('tab_manager.kind_badge.materialized_view')
: t('tab_manager.kind_badge.view');
}
if (tab.type === 'event-def') return t('tab_manager.kind_badge.event');
if (tab.type === 'routine-def') return t('tab_manager.kind_badge.routine');
return t('tab_manager.kind_badge.fallback');
};
export const TAB_WORKBENCH_CLASS_NAME = 'tab-workbench';
const getTabKindTooltipLabel = (tab: TabData): string => {
if (tab.type === 'query') return t('tab_manager.hover.kind.query');
if (tab.type === 'table') return t('tab_manager.hover.kind.table');
if (tab.type === 'design') return t('tab_manager.hover.kind.design');
if (tab.type === 'table-overview') return t('tab_manager.hover.kind.table_overview');
if (tab.type === 'table-export') return t('tab_manager.hover.kind.table_export');
if (tab.type === 'redis-keys') return t('tab_manager.hover.kind.redis_keys');
if (tab.type === 'redis-command') return t('tab_manager.hover.kind.redis_command');
if (tab.type === 'redis-monitor') return t('tab_manager.hover.kind.redis_monitor');
if (tab.type === 'jvm-overview') return t('tab_manager.hover.kind.jvm_overview');
if (tab.type === 'jvm-resource') return t('tab_manager.hover.kind.jvm_resource');
if (tab.type === 'jvm-audit') return t('tab_manager.hover.kind.jvm_audit');
if (tab.type === 'jvm-diagnostic') return t('tab_manager.hover.kind.jvm_diagnostic');
if (tab.type === 'jvm-monitoring') return t('tab_manager.hover.kind.jvm_monitoring');
if (tab.type === 'trigger') return t('tab_manager.hover.kind.trigger');
if (tab.type === 'view-def') {
return tab.viewKind === 'materialized'
? t('tab_manager.hover.kind.materialized_view')
: t('tab_manager.hover.kind.view');
}
if (tab.type === 'event-def') return t('tab_manager.hover.kind.event');
if (tab.type === 'routine-def') return t('tab_manager.hover.kind.routine');
return t('tab_manager.hover.kind.fallback');
};
const getTabObjectLabel = (tab: TabData): string => {
if (tab.tableName) return tab.tableName;
if (tab.viewName) return tab.viewName;
if (tab.eventName) return tab.eventName;
if (tab.routineName) return tab.routineName;
if (tab.triggerName) return tab.triggerName;
if (tab.resourcePath) return tab.resourcePath;
if (tab.filePath) return tab.filePath;
if (tab.type.startsWith('redis')) return `db${tab.redisDB ?? 0}`;
return '';
};
const getCloseOtherTabIds = (tabs: TabData[], id: string): string[] =>
tabs.filter((tab) => tab.id !== id).map((tab) => tab.id);
const getCloseTabsToLeftIds = (tabs: TabData[], id: string): string[] => {
const index = tabs.findIndex((tab) => tab.id === id);
if (index <= 0) return [];
return tabs.slice(0, index).map((tab) => tab.id);
};
const getCloseTabsToRightIds = (tabs: TabData[], id: string): string[] => {
const index = tabs.findIndex((tab) => tab.id === id);
if (index < 0 || index >= tabs.length - 1) return [];
return tabs.slice(index + 1).map((tab) => tab.id);
};
export const stopTabHoverDragPropagation = (event: React.SyntheticEvent<HTMLElement>) => {
event.stopPropagation();
};
export const resolveTabHoverOpen = (isHoverInfoOpen: boolean, isTabMenuOpen: boolean) =>
isHoverInfoOpen && !isTabMenuOpen;
export const openTabDisplaySettings = () => {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new CustomEvent('gonavi:open-tab-display-settings'));
};
export const shouldShowV2ConnectionLabel = (displayTitle: string, connectionLabel?: string): boolean => {
const normalizedConnectionLabel = String(connectionLabel || '').trim();
if (!normalizedConnectionLabel) {
return false;
}
const normalizedDisplayTitle = String(displayTitle || '').trim();
if (!normalizedDisplayTitle) {
return true;
}
const escapedConnectionLabel = normalizedConnectionLabel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const prefixedConnectionPattern = new RegExp(`^\\[${escapedConnectionLabel}(?:\\s*[|\\]])`, 'i');
return !prefixedConnectionPattern.test(normalizedDisplayTitle);
};
export const resolveTabHoverTitle = (displayModel: TabDisplayModel | undefined, fallbackTitle: string): string => {
if (!displayModel) {
return fallbackTitle;
}
const objectPart = [...displayModel.primaryParts, ...displayModel.secondaryParts]
.find((part) => part.key === 'object');
if (objectPart?.text) {
return objectPart.text;
}
const primaryText = displayModel.primaryParts
.filter((part) => part.key !== 'kind')
.map((part) => part.text)
.join(' ')
.trim();
return primaryText || displayModel.primaryText || fallbackTitle;
};
type TabHoverInfoProps = {
tab: TabData;
displayModel?: TabDisplayModel;
displayTitle: string;
connectionLabel?: string;
hostSummary?: string;
};
export const TabHoverInfo: React.FC<TabHoverInfoProps> = ({
tab,
displayModel,
displayTitle,
connectionLabel,
hostSummary,
}) => {
const objectLabel = getTabObjectLabel(tab);
const hoverTitle = resolveTabHoverTitle(displayModel, displayTitle);
const schemaPart = displayModel
? [...displayModel.primaryParts, ...displayModel.secondaryParts].find((part) => part.key === 'schema')
: undefined;
const rows = [
[t('tab_manager.hover.label.type'), getTabKindTooltipLabel(tab)],
[t('tab_manager.hover.label.connection'), connectionLabel || t('tab_manager.hover.fallback.unbound_connection')],
['Host', hostSummary || t('tab_manager.hover.fallback.host_not_configured')],
[t('tab_manager.hover.label.database'), tab.dbName || t('tab_manager.hover.fallback.database_not_specified')],
['Schema', schemaPart?.value],
[t('tab_manager.hover.label.object'), objectLabel],
].filter(([, value]) => Boolean(value));
return (
<div
className="gn-v2-tab-hover-card"
data-tab-hover-info="true"
onPointerDown={stopTabHoverDragPropagation}
onPointerMove={stopTabHoverDragPropagation}
onPointerUp={stopTabHoverDragPropagation}
onPointerDownCapture={stopTabHoverDragPropagation}
onPointerUpCapture={stopTabHoverDragPropagation}
onMouseDown={stopTabHoverDragPropagation}
onMouseMove={stopTabHoverDragPropagation}
onMouseUp={stopTabHoverDragPropagation}
onClick={stopTabHoverDragPropagation}
onClickCapture={stopTabHoverDragPropagation}
onTouchStart={stopTabHoverDragPropagation}
onTouchMove={stopTabHoverDragPropagation}
onTouchEnd={stopTabHoverDragPropagation}
>
<div className="gn-v2-tab-hover-head">
<span>{getTabKindLabel(tab)}</span>
<strong>{hoverTitle}</strong>
</div>
<div className="gn-v2-tab-hover-rows">
{rows.map(([label, value]) => (
<div className="gn-v2-tab-hover-row" key={label}>
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
</div>
);
};
type SortableTabLabelProps = {
tab: TabData;
displayModel: TabDisplayModel;
displayTitle: string;
menuItems: MenuProps['items'];
connectionLabel?: string;
hostSummary?: string;
isV2Ui?: boolean;
onClose?: () => void;
};
const renderV2TabDisplayPart = (part: TabDisplayPart) => {
if (part.key === 'kind') {
return (
<span className="gn-v2-tab-kind" key={part.key}>
{part.text}
</span>
);
}
return (
<span className={`gn-v2-tab-label-part gn-v2-tab-label-part-${part.key}`} key={part.key}>
{part.text}
</span>
);
};
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
tab,
displayModel,
displayTitle,
menuItems,
connectionLabel,
hostSummary,
isV2Ui,
onClose,
}) => {
const [isHoverInfoOpen, setIsHoverInfoOpen] = useState(false);
const [isTabMenuOpen, setIsTabMenuOpen] = useState(false);
const handleTabLabelContextMenu = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
setIsHoverInfoOpen(false);
setIsTabMenuOpen(true);
};
const handleTabMenuOpenChange = (open: boolean) => {
setIsTabMenuOpen(open);
setIsHoverInfoOpen(false);
};
const handleHoverInfoOpenChange = (open: boolean) => {
setIsHoverInfoOpen(open && !isTabMenuOpen);
};
const tabDisplayPartCount = displayModel.primaryParts.length + displayModel.secondaryParts.length;
const showSecondaryLine = isV2Ui && displayModel.layout === 'double' && Boolean(displayModel.secondaryText);
const labelNode = (
<span
className={`tab-dnd-label${isV2Ui ? ' gn-v2-tab-label' : ''}${showSecondaryLine ? ' gn-v2-tab-label-double' : ''}${tabDisplayPartCount >= 4 ? ' gn-v2-tab-label-rich' : ''}`}
onContextMenu={handleTabLabelContextMenu}
title={isV2Ui ? undefined : displayTitle}
>
{isV2Ui ? (
<span className="gn-v2-tab-label-content">
<span className="gn-v2-tab-label-main tab-title-text">
{displayModel.primaryParts.length > 0
? displayModel.primaryParts.map(renderV2TabDisplayPart)
: displayModel.primaryText}
</span>
{showSecondaryLine ? (
<span className="gn-v2-tab-label-secondary" title={displayModel.secondaryText}>
{displayModel.secondaryText}
</span>
) : null}
</span>
) : (
<span className="tab-title-text">{displayTitle}</span>
)}
{isV2Ui && onClose ? (
<button
type="button"
className="gn-v2-tab-close"
aria-label={t('tab_manager.close_aria', { title: displayTitle })}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onClose();
}}
>
<CloseOutlined />
</button>
) : null}
</span>
);
const wrappedLabel = isV2Ui ? (
<Tooltip
title={(
<TabHoverInfo
tab={tab}
displayModel={displayModel}
displayTitle={displayTitle}
connectionLabel={connectionLabel}
hostSummary={hostSummary}
/>
)}
placement="bottomLeft"
mouseEnterDelay={1.2}
open={resolveTabHoverOpen(isHoverInfoOpen, isTabMenuOpen)}
onOpenChange={handleHoverInfoOpenChange}
destroyOnHidden
rootClassName="gn-v2-tab-hover-tooltip"
>
{labelNode}
</Tooltip>
) : labelNode;
return (
<Dropdown
menu={{ items: menuItems }}
trigger={['contextMenu']}
onOpenChange={handleTabMenuOpenChange}
rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}
>
{wrappedLabel}
</Dropdown>
);
};
type DraggableTabNodeProps = {
node: React.ReactElement;
};
const DraggableTabNode: React.FC<DraggableTabNodeProps> = ({ node }) => {
const tabId = String(node.key || '').trim();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tabId });
const style: React.CSSProperties = {
...(node.props.style || {}),
transform: CSS.Transform.toString(transform),
transition: transition || 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1)',
opacity: isDragging ? 0.88 : 1,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none',
zIndex: isDragging ? 2 : node.props.style?.zIndex,
};
return React.cloneElement(node, {
ref: setNodeRef,
style,
...attributes,
...listeners,
className: `${node.props.className || ''} tab-dnd-node${isDragging ? ' is-dragging' : ''}`,
});
};
const TabContent: React.FC<{ tab: TabData; isActive: boolean }> = React.memo(({ tab, isActive }) => {
if (tab.type === 'query') {
return <QueryEditor tab={tab} isActive={isActive} />;
}
if (tab.type === 'table') {
return <DataViewer tab={tab} isActive={isActive} />;
}
if (tab.type === 'design') {
return <TableDesigner tab={tab} />;
}
if (tab.type === 'redis-keys') {
return <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
}
if (tab.type === 'redis-command') {
return <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
}
if (tab.type === 'redis-monitor') {
return <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
}
if (tab.type === 'trigger') {
return <TriggerViewer tab={tab} />;
}
if (tab.type === 'view-def' || tab.type === 'event-def' || tab.type === 'routine-def') {
return <DefinitionViewer tab={tab} />;
}
if (tab.type === 'table-overview') {
return <TableOverview tab={tab} />;
}
if (tab.type === 'table-export') {
return <TableExportWorkbench tab={tab} />;
}
if (tab.type === 'jvm-overview') {
return <JVMOverview tab={tab} />;
}
if (tab.type === 'jvm-resource') {
return <JVMResourceBrowser tab={tab} />;
}
if (tab.type === 'jvm-audit') {
return <JVMAuditViewer tab={tab} />;
}
if (tab.type === 'jvm-diagnostic') {
return <JVMDiagnosticConsole tab={tab} />;
}
if (tab.type === 'jvm-monitoring') {
return <JVMMonitoringDashboard tab={tab} />;
}
return null;
});
const TabManager: React.FC = React.memo(() => {
const tabs = useStore(state => state.tabs);
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const languagePreference = useStore(state => state.languagePreference);
const activeTabId = useStore(state => state.activeTabId);
const setActiveTab = useStore(state => state.setActiveTab);
const addTab = useStore(state => state.addTab);
const closeTab = useStore(state => state.closeTab);
const closeOtherTabs = useStore(state => state.closeOtherTabs);
const closeTabsToLeft = useStore(state => state.closeTabsToLeft);
const closeTabsToRight = useStore(state => state.closeTabsToRight);
const closeAllTabs = useStore(state => state.closeAllTabs);
const moveTab = useStore(state => state.moveTab);
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)';
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
const suppressClickUntilRef = useRef<number>(0);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const isV2Ui = appearance.uiVersion === 'v2';
const hasTabs = tabs.length > 0;
const pendingCloseTabIdsRef = useRef<Set<string>>(new Set());
const onChange = (newActiveKey: string) => {
setActiveTab(newActiveKey);
};
const requestCloseSQLFileTabs = useCallback(async (
targetTabs: TabData[],
closeConfirmedTabs: () => void,
) => {
const candidateTabs = targetTabs.filter(isSQLFileQueryTab);
if (candidateTabs.length === 0) {
closeConfirmedTabs();
return;
}
const closeConfirmedTabsAndClearDrafts = () => {
closeConfirmedTabs();
candidateTabs.forEach((tab) => clearSQLFileTabDraft(tab.id));
};
const dirtyTabs: Array<{ tab: TabData; draft: string }> = [];
const missingFileTabs: Array<{ tab: TabData; filePath: string }> = [];
for (const tab of candidateTabs) {
const filePath = getSQLFileTabPath(tab);
if (!filePath) continue;
try {
const res = await ReadSQLFile(filePath);
if (!res.success) {
if (isSQLFileMissingReadResult(res)) {
missingFileTabs.push({ tab, filePath });
continue;
}
message.error(`读取 SQL 文件失败,已取消关闭:${res.message || filePath}`);
return;
}
const draft = getSQLFileTabDraft(tab.id, String(tab.query ?? ''));
if (hasSQLFileTabUnsavedChanges({ ...tab, query: draft }, normalizeSQLFileReadContent(res.data))) {
dirtyTabs.push({ tab, draft });
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (isSQLFileMissingErrorMessage(errorMessage)) {
missingFileTabs.push({ tab, filePath });
continue;
}
message.error('读取 SQL 文件失败,已取消关闭:' + errorMessage);
return;
}
}
const confirmDirtyTabsOrClose = () => {
if (dirtyTabs.length === 0) {
closeConfirmedTabsAndClearDrafts();
return;
}
const firstDirtyTab = dirtyTabs[0].tab;
const dirtyFilePath = getSQLFileTabPath(firstDirtyTab);
const dirtyLabel = dirtyTabs.length === 1
? `${firstDirtyTab.title || dirtyFilePath}`
: `${dirtyTabs.length} 个 SQL 文件`;
let destroyConfirm: (() => void) | null = null;
const confirmRef = Modal.confirm({
title: '保存 SQL 文件修改?',
content: `${dirtyLabel} 有未保存修改,是否保存后再关闭?`,
okText: '保存并关闭',
cancelText: '取消',
closable: true,
maskClosable: true,
okButtonProps: { type: 'primary' },
footer: (_, { OkBtn, CancelBtn }) => (
<>
<Button
onClick={() => {
destroyConfirm?.();
closeConfirmedTabsAndClearDrafts();
}}
>
</Button>
<CancelBtn />
<OkBtn />
</>
),
onOk: async () => {
try {
for (const { tab, draft } of dirtyTabs) {
const filePath = getSQLFileTabPath(tab);
if (!filePath) continue;
const res = await WriteSQLFile(filePath, draft);
if (!res.success) {
throw new Error(`保存 ${tab.title || filePath} 失败:${res.message || '未知错误'}`);
}
}
message.success('SQL 文件已保存');
closeConfirmedTabsAndClearDrafts();
} catch (error) {
message.error(error instanceof Error ? error.message : String(error));
throw error;
}
},
});
destroyConfirm = confirmRef.destroy;
};
if (missingFileTabs.length > 0) {
const firstMissing = missingFileTabs[0];
const missingLabel = missingFileTabs.length === 1
? `${firstMissing.tab.title || firstMissing.filePath}`
: `${missingFileTabs.length} 个 SQL 文件标签`;
Modal.confirm({
title: '关闭已丢失的 SQL 文件标签?',
content: `${missingLabel} 对应的外部 SQL 文件已不存在或已被移动,关闭后将丢弃标签内的本地草稿。`,
okText: dirtyTabs.length > 0 ? '继续关闭' : '关闭标签',
cancelText: '取消',
closable: true,
maskClosable: true,
okButtonProps: { danger: true },
onOk: () => {
confirmDirtyTabsOrClose();
},
});
return;
}
confirmDirtyTabsOrClose();
}, []);
const closeTabsWithSQLFilePrompt = useCallback((targetIds: string[], closeConfirmedTabs: () => void) => {
const uniqueIds = Array.from(new Set(targetIds.map((id) => String(id || '').trim()).filter(Boolean)));
if (uniqueIds.length === 0) return;
const dedupeKey = uniqueIds.slice().sort().join('\n');
if (pendingCloseTabIdsRef.current.has(dedupeKey)) return;
pendingCloseTabIdsRef.current.add(dedupeKey);
const targetTabs = tabs.filter((tab) => uniqueIds.includes(tab.id));
void requestCloseSQLFileTabs(targetTabs, closeConfirmedTabs).finally(() => {
pendingCloseTabIdsRef.current.delete(dedupeKey);
});
}, [requestCloseSQLFileTabs, tabs]);
const onEdit = (targetKey: React.MouseEvent | React.KeyboardEvent | string, action: 'add' | 'remove') => {
if (action === 'remove') {
const id = String(targetKey || '');
closeTabsWithSQLFilePrompt([id], () => closeTab(id));
}
};
const handleDragStart = (event: DragStartEvent) => {
const sourceId = String(event.active.id || '').trim();
setDraggingTabId(sourceId || null);
};
const handleDragEnd = (event: DragEndEvent) => {
const sourceId = String(event.active.id || '').trim();
const targetId = String(event.over?.id || '').trim();
setDraggingTabId(null);
if (!sourceId || !targetId || sourceId === targetId) {
return;
}
suppressClickUntilRef.current = Date.now() + 120;
moveTab(sourceId, targetId);
};
const handleDragCancel = () => {
setDraggingTabId(null);
};
React.useEffect(() => {
const handleGlobalInsertSql = (e: any) => {
const { sql, runImmediately, connectionId: eventConnId, dbName: eventDbName } = e.detail;
if (!sql) return;
const activeTab = tabs.find(t => t.id === activeTabId);
// 🔧 runImmediately点击"执行")始终新建独立 tab避免追加到已有 tab 导致 SQL 重复
if (runImmediately) {
const newTabId = 'tab-' + Date.now();
const resolvedConnId = eventConnId || activeTab?.connectionId || (connections.length > 0 ? connections[0].id : '');
const resolvedDbName = eventConnId ? (eventDbName || '') : (activeTab?.dbName || '');
addTab({
id: newTabId,
type: 'query',
title: t('query.new'),
query: sql,
connectionId: resolvedConnId,
dbName: resolvedDbName
});
setActiveTab(newTabId);
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql-to-tab', {
detail: { tabId: newTabId, sql, runImmediately: true, connectionId: resolvedConnId, dbName: resolvedDbName }
}));
}, 300);
return;
}
// 插入模式:追加到已有 tab 或新建 tab
if (activeTab && activeTab.type === 'query') {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql-to-tab', {
detail: { tabId: activeTab.id, sql, runImmediately: false, connectionId: eventConnId, dbName: eventDbName }
}));
} else {
const newTabId = 'tab-' + Date.now();
const resolvedConnId = eventConnId || activeTab?.connectionId || (connections.length > 0 ? connections[0].id : '');
const resolvedDbName = eventConnId ? (eventDbName || '') : (activeTab?.dbName || '');
addTab({
id: newTabId,
type: 'query',
title: t('query.new'),
query: sql,
connectionId: resolvedConnId,
dbName: resolvedDbName
});
setActiveTab(newTabId);
}
};
window.addEventListener('gonavi:insert-sql', handleGlobalInsertSql);
return () => window.removeEventListener('gonavi:insert-sql', handleGlobalInsertSql);
}, [tabs, activeTabId, addTab, setActiveTab, connections]);
const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]);
const hasDoubleLineTabLabel = useMemo(() => (
tabs.some((tab) => {
const connection = connections.find((conn) => conn.id === tab.connectionId);
const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay);
return displayModel.layout === 'double' && Boolean(displayModel.secondaryText);
})
), [appearance.tabDisplay, connections, tabs]);
const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => (
<DefaultTabBar {...tabBarProps}>
{(node) => <DraggableTabNode key={node.key} node={node} />}
</DefaultTabBar>
);
const items = useMemo(() => tabs.map((tab, index) => {
const connection = connections.find((conn) => conn.id === tab.connectionId);
const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay);
const displayTitle = displayModel.fullTitle;
const hostSummary = resolveConnectionHostSummary(connection?.config);
const tabIsActive = tab.id === activeTabId;
const menuItems: MenuProps['items'] = [
{
key: 'tab-display-settings',
icon: <SettingOutlined />,
label: '标签设置',
onClick: openTabDisplaySettings,
},
{ type: 'divider' },
{
key: 'close-other',
label: t('tab_manager.menu.close_other'),
disabled: tabs.length <= 1,
onClick: () => closeTabsWithSQLFilePrompt(getCloseOtherTabIds(tabs, tab.id), () => closeOtherTabs(tab.id)),
},
{
key: 'close-left',
label: t('tab_manager.menu.close_left'),
disabled: index === 0,
onClick: () => closeTabsWithSQLFilePrompt(getCloseTabsToLeftIds(tabs, tab.id), () => closeTabsToLeft(tab.id)),
},
{
key: 'close-right',
label: t('tab_manager.menu.close_right'),
disabled: index === tabs.length - 1,
onClick: () => closeTabsWithSQLFilePrompt(getCloseTabsToRightIds(tabs, tab.id), () => closeTabsToRight(tab.id)),
},
{ type: 'divider' },
{
key: 'close-all',
label: t('tab_manager.menu.close_all'),
disabled: tabs.length === 0,
onClick: () => closeTabsWithSQLFilePrompt(tabs.map((item) => item.id), () => closeAllTabs()),
},
];
return {
label: (
<SortableTabLabel
tab={tab}
displayModel={displayModel}
displayTitle={displayTitle}
menuItems={menuItems}
connectionLabel={connection?.name}
hostSummary={hostSummary}
isV2Ui={isV2Ui}
onClose={() => closeTabsWithSQLFilePrompt([tab.id], () => closeTab(tab.id))}
/>
),
key: tab.id,
closable: !isV2Ui,
children: <TabContent tab={tab} isActive={tabIsActive} />,
};
}), [tabs, connections, appearance.tabDisplay, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, closeTab, closeTabsWithSQLFilePrompt, isV2Ui, languagePreference]);
const handleOpenConnectionModal = () => {
const target = document.querySelector<HTMLButtonElement>('[data-gonavi-create-connection-action="true"]');
target?.click();
};
const handleOpenAI = () => {
setAIPanelVisible(true);
};
const EmptyWorkbench = (
<div className="gn-v2-empty-workbench">
<section className="gn-v2-empty-hero" aria-label={t('tab_manager.empty.aria.start_workbench')}>
<div className="gn-v2-empty-eyebrow">
<span>{t('tab_manager.empty.eyebrow.workbench')}</span>
<span>{t('tab_manager.empty.eyebrow.connections', { count: connections.length })}</span>
</div>
<h1>{t('tab_manager.empty.hero.title')}</h1>
<p>{t('tab_manager.empty.hero.description')}</p>
<div className="gn-v2-empty-actions">
<Button type="primary" icon={<PlusOutlined />} onClick={handleOpenConnectionModal}>
{t('connection.new')}
</Button>
<Button icon={<ConsoleSqlOutlined />} onClick={() => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab'))}>
{t('query.new')}
</Button>
<Button icon={<RobotOutlined />} onClick={handleOpenAI}>
{t('tab_manager.empty.action.open_ai')}
</Button>
</div>
</section>
<section className="gn-v2-empty-panel" aria-label={t('tab_manager.empty.quick.aria')}>
<div className="gn-v2-panel-heading">
<span>{t('tab_manager.empty.quick.heading')}</span>
<AppstoreOutlined />
</div>
<button type="button" onClick={handleOpenConnectionModal}>
<DatabaseOutlined />
<span>
<strong>{t('tab_manager.empty.quick.configure_source.title')}</strong>
<small>{t('tab_manager.empty.quick.configure_source.description')}</small>
</span>
</button>
<button type="button" onClick={() => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab'))}>
<ConsoleSqlOutlined />
<span>
<strong>{t('tab_manager.empty.quick.sql_workspace.title')}</strong>
<small>{t('tab_manager.empty.quick.sql_workspace.description')}</small>
</span>
</button>
<button type="button" onClick={handleOpenAI}>
<RobotOutlined />
<span>
<strong>{t('tab_manager.empty.quick.ai_assist.title')}</strong>
<small>{t('tab_manager.empty.quick.ai_assist.description')}</small>
</span>
</button>
</section>
</div>
);
return (
<div className={`${TAB_WORKBENCH_CLASS_NAME}${isV2Ui ? ' gn-v2-tab-workbench' : ''}`}>
<style>{`
.${TAB_WORKBENCH_CLASS_NAME} {
height: 100%;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.main-tabs {
height: 100%;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.main-tabs .ant-tabs-nav {
flex: 0 0 auto;
margin: 0;
}
.main-tabs .ant-tabs-content-holder {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.main-tabs .ant-tabs-content {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
.main-tabs .ant-tabs-tabpane {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.main-tabs .ant-tabs-tabpane > div {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
}
.main-tabs .ant-tabs-tabpane-hidden {
display: none !important;
}
.main-tabs .ant-tabs-nav::before {
border-bottom: 1px solid ${tabsNavBorderColor} !important;
}
.main-tabs .ant-tabs-tab {
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1), background-color 120ms ease;
}
.main-tabs .tab-dnd-label {
user-select: none;
-webkit-user-select: none;
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 100%;
}
.main-tabs .tab-title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.main-tabs .tab-dnd-node.is-dragging,
.main-tabs .tab-dnd-node.is-dragging .tab-dnd-label {
cursor: grabbing !important;
}
body[data-theme='dark'] .main-tabs .ant-tabs-tab-btn:focus-visible {
outline: none !important;
border-radius: 6px;
box-shadow: 0 0 0 2px rgba(255, 214, 102, 0.72);
background: rgba(255, 214, 102, 0.16);
}
body[data-theme='light'] .main-tabs .ant-tabs-tab-btn:focus-visible {
outline: none !important;
border-radius: 6px;
box-shadow: 0 0 0 2px rgba(9, 109, 217, 0.32);
background: rgba(9, 109, 217, 0.08);
}
body[data-theme='light'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
background: rgba(24, 144, 255, 0.10) !important;
border-color: rgba(24, 144, 255, 0.28) !important;
}
body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
background: rgba(255, 214, 102, 0.12) !important;
border-color: rgba(255, 214, 102, 0.4) !important;
}
body[data-ui-version='v2'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
background: var(--gn-bg-panel) !important;
border-color: var(--gn-br-2) !important;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-tooltip .ant-tooltip-inner {
min-width: 260px;
padding: 0;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-tooltip {
pointer-events: auto;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-card {
--gn-v2-tab-hover-grid-columns: 56px minmax(0, 1fr);
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
color: var(--gn-fg-2);
cursor: text;
user-select: text;
-webkit-user-select: text;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-card * {
user-select: text;
-webkit-user-select: text;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-head {
display: grid;
grid-template-columns: var(--gn-v2-tab-hover-grid-columns);
align-items: start;
gap: 8px;
min-width: 0;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-head > span {
justify-self: start;
padding: 2px 6px;
border-radius: 5px;
background: var(--gn-bg-active);
color: var(--gn-accent-2);
font-family: var(--gn-font-mono);
font-size: 10px;
font-weight: 700;
line-height: 14px;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-head > strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--gn-fg-1);
font-size: var(--gn-font-size-sm, 12px);
font-weight: 700;
line-height: 18px;
white-space: normal;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-rows {
display: grid;
gap: 5px;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-row {
display: grid;
grid-template-columns: var(--gn-v2-tab-hover-grid-columns);
align-items: start;
gap: 8px;
font-size: var(--gn-font-size-sm, 12px);
line-height: 18px;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-row > span {
color: var(--gn-fg-5);
}
body[data-ui-version='v2'] .gn-v2-tab-hover-row > strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--gn-fg-2);
font-weight: 600;
}
`}</style>
{isV2Ui && !hasTabs ? (
EmptyWorkbench
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
<Tabs
className={`main-tabs${isV2Ui ? ' gn-v2-main-tabs' : ''}${hasDoubleLineTabLabel ? ' gn-v2-main-tabs-double' : ''}`}
type="editable-card"
destroyOnHidden={false}
onChange={(newActiveKey) => {
if (Date.now() < suppressClickUntilRef.current) return;
onChange(newActiveKey);
}}
activeKey={activeTabId || undefined}
onEdit={onEdit}
items={items}
hideAdd
renderTabBar={renderTabBar}
/>
</SortableContext>
</DndContext>
)}
</div>
);
});
export default TabManager;