mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-20 20:35:14 +08:00
✨ feat(tab/window): 支持主工作区与结果Tab拆出为应用内浮动窗口并还原
- 新增 detached 会话态:主工作区窗口与查询结果窗口独立管理 - 主Tab支持右键拆出与垂直拖出,浮动窗可拖拽缩放并还原/关闭 - 结果Tab右键拆出为浮动结果窗,支持还原回源查询结果区 - 抽离 WorkbenchTabContent,QueryEditor 结果会话缓存避免拆出丢失 - 补充六语言文案与 detach/store 相关单元测试
This commit is contained in:
312
frontend/src/components/FloatingQueryResultWindows.tsx
Normal file
312
frontend/src/components/FloatingQueryResultWindows.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user