mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-20 04:11:56 +08:00
✨ feat(tab/window): 支持主工作区与结果Tab拆出为应用内浮动窗口并还原
- 新增 detached 会话态:主工作区窗口与查询结果窗口独立管理 - 主Tab支持右键拆出与垂直拖出,浮动窗可拖拽缩放并还原/关闭 - 结果Tab右键拆出为浮动结果窗,支持还原回源查询结果区 - 抽离 WorkbenchTabContent,QueryEditor 结果会话缓存避免拆出丢失 - 补充六语言文案与 detach/store 相关单元测试
This commit is contained in:
40
frontend/src/utils/detachedWindow.test.ts
Normal file
40
frontend/src/utils/detachedWindow.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createDefaultDetachedBounds,
|
||||
DETACH_TAB_DRAG_Y_THRESHOLD,
|
||||
nextDetachedZIndex,
|
||||
resolveDetachedWindowTitle,
|
||||
shouldDetachTabByDrag,
|
||||
} from './detachedWindow';
|
||||
|
||||
describe('detachedWindow helpers', () => {
|
||||
it('computes next z-index above existing windows', () => {
|
||||
expect(nextDetachedZIndex([])).toBe(1201);
|
||||
expect(nextDetachedZIndex([{ zIndex: 1300 }, { zIndex: 1250 }])).toBe(1301);
|
||||
});
|
||||
|
||||
it('detaches only when vertical drag exceeds threshold', () => {
|
||||
expect(shouldDetachTabByDrag(DETACH_TAB_DRAG_Y_THRESHOLD - 1)).toBe(false);
|
||||
expect(shouldDetachTabByDrag(DETACH_TAB_DRAG_Y_THRESHOLD)).toBe(true);
|
||||
expect(shouldDetachTabByDrag(-DETACH_TAB_DRAG_Y_THRESHOLD)).toBe(true);
|
||||
});
|
||||
|
||||
it('builds a default floating window bounds', () => {
|
||||
const bounds = createDefaultDetachedBounds([]);
|
||||
expect(bounds.width).toBeGreaterThan(0);
|
||||
expect(bounds.height).toBeGreaterThan(0);
|
||||
expect(bounds.zIndex).toBeGreaterThan(1200);
|
||||
});
|
||||
|
||||
it('resolves floating window titles with object labels', () => {
|
||||
expect(resolveDetachedWindowTitle({
|
||||
kindLabel: '表数据',
|
||||
objectLabel: 'users',
|
||||
fallbackTitle: 'users',
|
||||
})).toBe('表数据 · users');
|
||||
expect(resolveDetachedWindowTitle({
|
||||
kindLabel: 'SQL 查询',
|
||||
fallbackTitle: 'Query 1',
|
||||
})).toBe('Query 1');
|
||||
});
|
||||
});
|
||||
129
frontend/src/utils/detachedWindow.ts
Normal file
129
frontend/src/utils/detachedWindow.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
export type DetachedWindowBounds = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
zIndex: number;
|
||||
};
|
||||
|
||||
export type DetachedWorkbenchWindow = DetachedWindowBounds & {
|
||||
tabId: string;
|
||||
};
|
||||
|
||||
export type DetachedQueryResultSnapshot = {
|
||||
key: string;
|
||||
sql: string;
|
||||
exportSql?: string;
|
||||
sourceStatementIndex?: number;
|
||||
statementResultIndex?: number;
|
||||
rows: any[];
|
||||
columns: string[];
|
||||
messages?: string[];
|
||||
resultType?: 'grid' | 'message';
|
||||
tableName?: string;
|
||||
pkColumns: string[];
|
||||
editLocator?: {
|
||||
strategy?: string;
|
||||
columns?: string[];
|
||||
values?: Record<string, unknown>;
|
||||
};
|
||||
readOnly: boolean;
|
||||
showRowNumberColumn?: boolean;
|
||||
truncated?: boolean;
|
||||
};
|
||||
|
||||
export type DetachedQueryResultWindow = DetachedWindowBounds & {
|
||||
id: string;
|
||||
sourceQueryTabId: string;
|
||||
connectionId: string;
|
||||
dbName?: string;
|
||||
title: string;
|
||||
result: DetachedQueryResultSnapshot;
|
||||
};
|
||||
|
||||
export const DETACH_TAB_DRAG_Y_THRESHOLD = 56;
|
||||
export const DEFAULT_DETACHED_WINDOW_WIDTH = 960;
|
||||
export const DEFAULT_DETACHED_WINDOW_HEIGHT = 640;
|
||||
export const DEFAULT_DETACHED_WINDOW_MIN_WIDTH = 480;
|
||||
export const DEFAULT_DETACHED_WINDOW_MIN_HEIGHT = 320;
|
||||
export const DETACHED_WINDOW_VIEWPORT_PADDING = 16;
|
||||
|
||||
export const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
export const nextDetachedZIndex = (windows: Array<{ zIndex?: number }>): number => {
|
||||
let max = 1200;
|
||||
for (const windowState of windows) {
|
||||
const z = Number(windowState?.zIndex);
|
||||
if (Number.isFinite(z) && z > max) {
|
||||
max = z;
|
||||
}
|
||||
}
|
||||
return max + 1;
|
||||
};
|
||||
|
||||
const getViewportSize = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
width: DEFAULT_DETACHED_WINDOW_WIDTH + DETACHED_WINDOW_VIEWPORT_PADDING * 2,
|
||||
height: DEFAULT_DETACHED_WINDOW_HEIGHT + DETACHED_WINDOW_VIEWPORT_PADDING * 2,
|
||||
};
|
||||
}
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
};
|
||||
|
||||
export const createDefaultDetachedBounds = (
|
||||
windows: Array<{ zIndex?: number }>,
|
||||
preferred?: Partial<Pick<DetachedWindowBounds, 'x' | 'y' | 'width' | 'height'>>,
|
||||
): DetachedWindowBounds => {
|
||||
const viewport = getViewportSize();
|
||||
const width = clamp(
|
||||
Number(preferred?.width) || DEFAULT_DETACHED_WINDOW_WIDTH,
|
||||
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
|
||||
Math.max(DEFAULT_DETACHED_WINDOW_MIN_WIDTH, viewport.width - DETACHED_WINDOW_VIEWPORT_PADDING * 2),
|
||||
);
|
||||
const height = clamp(
|
||||
Number(preferred?.height) || DEFAULT_DETACHED_WINDOW_HEIGHT,
|
||||
DEFAULT_DETACHED_WINDOW_MIN_HEIGHT,
|
||||
Math.max(DEFAULT_DETACHED_WINDOW_MIN_HEIGHT, viewport.height - DETACHED_WINDOW_VIEWPORT_PADDING * 2),
|
||||
);
|
||||
const cascade = (windows.length % 8) * 28;
|
||||
const defaultX = Math.max(
|
||||
DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
Math.round((viewport.width - width) / 2) + cascade,
|
||||
);
|
||||
const defaultY = Math.max(
|
||||
DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
Math.round((viewport.height - height) / 2) + cascade,
|
||||
);
|
||||
const maxX = Math.max(DETACHED_WINDOW_VIEWPORT_PADDING, viewport.width - width - DETACHED_WINDOW_VIEWPORT_PADDING);
|
||||
const maxY = Math.max(DETACHED_WINDOW_VIEWPORT_PADDING, viewport.height - height - DETACHED_WINDOW_VIEWPORT_PADDING);
|
||||
return {
|
||||
x: clamp(Number.isFinite(Number(preferred?.x)) ? Number(preferred?.x) : defaultX, DETACHED_WINDOW_VIEWPORT_PADDING, maxX),
|
||||
y: clamp(Number.isFinite(Number(preferred?.y)) ? Number(preferred?.y) : defaultY, DETACHED_WINDOW_VIEWPORT_PADDING, maxY),
|
||||
width,
|
||||
height,
|
||||
zIndex: nextDetachedZIndex(windows),
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldDetachTabByDrag = (deltaY: number, overId?: string | null): boolean => {
|
||||
if (!Number.isFinite(deltaY)) return false;
|
||||
// 垂直拖出超过阈值即判定为独立窗口;即使仍 hover 在其他 tab 上也可拆出
|
||||
return Math.abs(deltaY) >= DETACH_TAB_DRAG_Y_THRESHOLD;
|
||||
};
|
||||
|
||||
export const resolveDetachedWindowTitle = (params: {
|
||||
kindLabel: string;
|
||||
objectLabel?: string;
|
||||
fallbackTitle: string;
|
||||
}): string => {
|
||||
const objectLabel = String(params.objectLabel || '').trim();
|
||||
if (objectLabel) {
|
||||
return `${params.kindLabel} · ${objectLabel}`;
|
||||
}
|
||||
return String(params.fallbackTitle || params.kindLabel || '').trim() || params.kindLabel;
|
||||
};
|
||||
48
frontend/src/utils/queryEditorResultSessionCache.ts
Normal file
48
frontend/src/utils/queryEditorResultSessionCache.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { QueryEditorResultSet } from '../components/QueryEditorResultsPanel';
|
||||
|
||||
type QueryEditorResultSessionSnapshot = {
|
||||
resultSets: QueryEditorResultSet[];
|
||||
activeResultKey: string;
|
||||
isResultPanelVisible?: boolean;
|
||||
};
|
||||
|
||||
const cache = new Map<string, QueryEditorResultSessionSnapshot>();
|
||||
|
||||
export const saveQueryEditorResultSession = (
|
||||
tabId: string,
|
||||
snapshot: QueryEditorResultSessionSnapshot,
|
||||
): void => {
|
||||
const id = String(tabId || '').trim();
|
||||
if (!id) return;
|
||||
cache.set(id, {
|
||||
resultSets: Array.isArray(snapshot.resultSets) ? snapshot.resultSets : [],
|
||||
activeResultKey: String(snapshot.activeResultKey || ''),
|
||||
isResultPanelVisible: snapshot.isResultPanelVisible,
|
||||
});
|
||||
};
|
||||
|
||||
export const takeQueryEditorResultSession = (
|
||||
tabId: string,
|
||||
): QueryEditorResultSessionSnapshot | null => {
|
||||
const id = String(tabId || '').trim();
|
||||
if (!id) return null;
|
||||
const snapshot = cache.get(id) || null;
|
||||
if (snapshot) {
|
||||
cache.delete(id);
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
export const peekQueryEditorResultSession = (
|
||||
tabId: string,
|
||||
): QueryEditorResultSessionSnapshot | null => {
|
||||
const id = String(tabId || '').trim();
|
||||
if (!id) return null;
|
||||
return cache.get(id) || null;
|
||||
};
|
||||
|
||||
export const clearQueryEditorResultSession = (tabId: string): void => {
|
||||
const id = String(tabId || '').trim();
|
||||
if (!id) return;
|
||||
cache.delete(id);
|
||||
};
|
||||
Reference in New Issue
Block a user