Files
MyGoNavi/frontend/src/components/FloatingQueryResultWindows.tsx
Syngnat de868a2763 feat(tab/window): 支持主工作区与结果Tab拆出为应用内浮动窗口并还原
- 新增 detached 会话态:主工作区窗口与查询结果窗口独立管理
- 主Tab支持右键拆出与垂直拖出,浮动窗可拖拽缩放并还原/关闭
- 结果Tab右键拆出为浮动结果窗,支持还原回源查询结果区
- 抽离 WorkbenchTabContent,QueryEditor 结果会话缓存避免拆出丢失
- 补充六语言文案与 detach/store 相关单元测试
2026-07-09 09:08:50 +08:00

313 lines
11 KiB
TypeScript

import React, { useCallback, useMemo, useRef } from 'react';
import { Button, Tooltip } from 'antd';
import { CloseOutlined, CompressOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { t } from '../i18n';
import DataGrid from './DataGrid';
import {
clamp,
DEFAULT_DETACHED_WINDOW_MIN_HEIGHT,
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
DETACHED_WINDOW_VIEWPORT_PADDING,
} from '../utils/detachedWindow';
type DragMode = 'move' | 'resize-e' | 'resize-s' | 'resize-se';
const isAffectedRowsResult = (columns: string[]): boolean =>
columns.length === 1 && columns[0] === 'affectedRows';
const FloatingQueryResultWindows: React.FC = () => {
const theme = useStore((state) => state.theme);
const detachedQueryResultWindows = useStore((state) => state.detachedQueryResultWindows);
const attachQueryResultWindow = useStore((state) => state.attachQueryResultWindow);
const closeDetachedQueryResultWindow = useStore((state) => state.closeDetachedQueryResultWindow);
const updateDetachedQueryResultBounds = useStore((state) => state.updateDetachedQueryResultBounds);
const focusDetachedQueryResultWindow = useStore((state) => state.focusDetachedQueryResultWindow);
const dragRef = useRef<{
id: string;
mode: DragMode;
startX: number;
startY: number;
originX: number;
originY: number;
originW: number;
originH: number;
} | null>(null);
const startInteraction = useCallback((
event: React.PointerEvent,
id: string,
mode: DragMode,
bounds: { x: number; y: number; width: number; height: number },
) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
focusDetachedQueryResultWindow(id);
dragRef.current = {
id,
mode,
startX: event.clientX,
startY: event.clientY,
originX: bounds.x,
originY: bounds.y,
originW: bounds.width,
originH: bounds.height,
};
const handleMove = (moveEvent: PointerEvent) => {
const drag = dragRef.current;
if (!drag) return;
const dx = moveEvent.clientX - drag.startX;
const dy = moveEvent.clientY - drag.startY;
if (drag.mode === 'move') {
const maxX = Math.max(
DETACHED_WINDOW_VIEWPORT_PADDING,
window.innerWidth - drag.originW - DETACHED_WINDOW_VIEWPORT_PADDING,
);
const maxY = Math.max(
DETACHED_WINDOW_VIEWPORT_PADDING,
window.innerHeight - drag.originH - DETACHED_WINDOW_VIEWPORT_PADDING,
);
updateDetachedQueryResultBounds(drag.id, {
x: clamp(drag.originX + dx, DETACHED_WINDOW_VIEWPORT_PADDING, maxX),
y: clamp(drag.originY + dy, DETACHED_WINDOW_VIEWPORT_PADDING, maxY),
});
return;
}
let nextW = drag.originW;
let nextH = drag.originH;
if (drag.mode === 'resize-e' || drag.mode === 'resize-se') {
nextW = clamp(
drag.originW + dx,
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
window.innerWidth - drag.originX - DETACHED_WINDOW_VIEWPORT_PADDING,
);
}
if (drag.mode === 'resize-s' || drag.mode === 'resize-se') {
nextH = clamp(
drag.originH + dy,
DEFAULT_DETACHED_WINDOW_MIN_HEIGHT,
window.innerHeight - drag.originY - DETACHED_WINDOW_VIEWPORT_PADDING,
);
}
updateDetachedQueryResultBounds(drag.id, { width: nextW, height: nextH });
};
const stop = () => {
dragRef.current = null;
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', stop);
window.removeEventListener('pointercancel', stop);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', stop);
window.addEventListener('pointercancel', stop);
}, [focusDetachedQueryResultWindow, updateDetachedQueryResultBounds]);
const handleRestore = useCallback((id: string) => {
const restored = attachQueryResultWindow(id);
if (!restored) return;
window.dispatchEvent(new CustomEvent('gonavi:restore-query-result', {
detail: {
sourceQueryTabId: restored.sourceQueryTabId,
result: restored.result,
},
}));
}, [attachQueryResultWindow]);
const windows = useMemo(() => detachedQueryResultWindows, [detachedQueryResultWindows]);
if (windows.length === 0) {
return null;
}
const isDark = theme === 'dark';
return (
<div className="gn-detached-result-layer" aria-label={t('query_editor.results_panel.detached.title', { index: '' })}>
<style>{`
.gn-detached-result-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1210;
}
.gn-detached-result-window {
position: fixed;
display: flex;
flex-direction: column;
min-width: ${DEFAULT_DETACHED_WINDOW_MIN_WIDTH}px;
min-height: ${DEFAULT_DETACHED_WINDOW_MIN_HEIGHT}px;
border-radius: 10px;
border: 1px solid ${isDark ? 'rgba(255,255,255,0.14)' : 'rgba(0,0,0,0.12)'};
background: ${isDark ? 'rgba(22,24,28,0.98)' : 'rgba(255,255,255,0.98)'};
box-shadow: ${isDark
? '0 18px 48px rgba(0,0,0,0.45)'
: '0 18px 48px rgba(15,23,42,0.18)'};
overflow: hidden;
pointer-events: auto;
}
.gn-detached-result-header {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 6px 8px 6px 12px;
border-bottom: 1px solid ${isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
background: ${isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)'};
cursor: move;
user-select: none;
}
.gn-detached-result-title {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 600;
}
.gn-detached-result-body {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
.gn-detached-result-message {
flex: 1 1 auto;
margin: 0;
padding: 12px;
border: none;
resize: none;
outline: none;
font-family: var(--gn-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);
font-size: 12px;
line-height: 1.5;
background: transparent;
color: inherit;
}
.gn-detached-result-resize-e {
position: absolute;
top: 36px;
right: 0;
width: 8px;
height: calc(100% - 36px);
cursor: ew-resize;
}
.gn-detached-result-resize-s {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 8px;
cursor: ns-resize;
}
.gn-detached-result-resize-se {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
}
`}</style>
{windows.map((windowState) => {
const isMessage =
windowState.result.resultType === 'message' ||
isAffectedRowsResult(windowState.result.columns || []);
const messageText = (windowState.result.messages || []).join('\n')
|| (isAffectedRowsResult(windowState.result.columns || [])
? String(windowState.result.rows?.[0]?.affectedRows ?? '')
: '');
return (
<div
key={windowState.id}
className="gn-detached-result-window"
style={{
left: windowState.x,
top: windowState.y,
width: windowState.width,
height: windowState.height,
zIndex: windowState.zIndex,
}}
onMouseDown={() => focusDetachedQueryResultWindow(windowState.id)}
>
<div
className="gn-detached-result-header"
onPointerDown={(event) => startInteraction(event, windowState.id, 'move', windowState)}
>
<div className="gn-detached-result-title" title={windowState.title}>
{windowState.title}
</div>
<div onPointerDown={(event) => event.stopPropagation()} style={{ display: 'inline-flex', gap: 4 }}>
<Tooltip title={t('query_editor.results_panel.detached.restore')}>
<Button
type="text"
size="small"
icon={<CompressOutlined />}
aria-label={t('query_editor.results_panel.detached.restore')}
onClick={() => handleRestore(windowState.id)}
/>
</Tooltip>
<Tooltip title={t('query_editor.results_panel.detached.close')}>
<Button
type="text"
size="small"
icon={<CloseOutlined />}
aria-label={t('query_editor.results_panel.detached.close')}
onClick={() => closeDetachedQueryResultWindow(windowState.id)}
/>
</Tooltip>
</div>
</div>
<div className="gn-detached-result-body">
{isMessage ? (
<textarea
className="gn-detached-result-message"
readOnly
value={messageText}
/>
) : (
<DataGrid
data={windowState.result.rows || []}
columnNames={windowState.result.columns || []}
loading={false}
tableName={windowState.result.tableName}
pkColumns={windowState.result.pkColumns || []}
editLocator={windowState.result.editLocator as any}
readOnly={windowState.result.readOnly !== false}
connectionId={windowState.connectionId}
dbName={windowState.dbName || ''}
resultSql={windowState.result.exportSql || windowState.result.sql}
showRowNumberColumn={windowState.result.showRowNumberColumn}
exportScope="queryResult"
/>
)}
</div>
<div
className="gn-detached-result-resize-e"
onPointerDown={(event) => startInteraction(event, windowState.id, 'resize-e', windowState)}
/>
<div
className="gn-detached-result-resize-s"
onPointerDown={(event) => startInteraction(event, windowState.id, 'resize-s', windowState)}
/>
<div
className="gn-detached-result-resize-se"
onPointerDown={(event) => startInteraction(event, windowState.id, 'resize-se', windowState)}
/>
</div>
);
})}
</div>
);
};
export default FloatingQueryResultWindows;