mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-23 22:20:13 +08:00
✨ feat(saved-query): 支持已存查询后端持久化与连接重绑
- 后端新增 saved_queries.json 仓库,保存、导入、删除和重绑统一走 Wails 方法 - 启动时导入旧 lite-db-storage 中的 savedQueries 和连接快照,成功后清理旧字段 - 新增连接指纹匹配,唯一强匹配自动重绑,歧义场景保留为未匹配 - 侧边栏新增未匹配已存查询分组,并支持手动绑定到目标连接 - 前端保存、重命名、删除查询改为后端持久化,并补充浏览器 mock - 补充后端与前端迁移回归测试
This commit is contained in:
@@ -458,6 +458,7 @@ describe('QueryEditor external SQL save', () => {
|
||||
storeState.addTab.mockReset();
|
||||
storeState.setActiveContext.mockReset();
|
||||
storeState.saveQuery.mockReset();
|
||||
storeState.saveQuery.mockImplementation(async (query: SavedQuery) => query);
|
||||
storeState.savedQueries = [];
|
||||
storeState.activeTabId = 'tab-1';
|
||||
storeState.queryOptions = {
|
||||
|
||||
@@ -5192,7 +5192,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return rawTitle;
|
||||
};
|
||||
|
||||
const persistQuery = (payload: { id: string; name: string; createdAt?: number }) => {
|
||||
const persistQuery = async (payload: { id: string; name: string; createdAt?: number }) => {
|
||||
const sql = getCurrentQuery();
|
||||
const saved = {
|
||||
id: payload.id,
|
||||
@@ -5202,16 +5202,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
dbName: currentDb || tab.dbName || '',
|
||||
createdAt: payload.createdAt ?? Date.now(),
|
||||
};
|
||||
saveQuery(saved);
|
||||
const persisted = await saveQuery(saved);
|
||||
addTab({
|
||||
...tab,
|
||||
title: payload.name,
|
||||
title: persisted.name,
|
||||
query: sql,
|
||||
connectionId: currentConnectionId,
|
||||
dbName: currentDb || tab.dbName || '',
|
||||
savedQueryId: payload.id,
|
||||
savedQueryId: persisted.id,
|
||||
});
|
||||
return saved;
|
||||
return persisted;
|
||||
};
|
||||
|
||||
const openSaveQueryModal = (mode: 'save' | 'rename') => {
|
||||
@@ -5254,8 +5254,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return;
|
||||
}
|
||||
const saveName = existed?.name || resolveDefaultQueryName();
|
||||
persistQuery({ id: saveId, name: saveName, createdAt: existed?.createdAt });
|
||||
message.success('查询已保存!');
|
||||
try {
|
||||
await persistQuery({ id: saveId, name: saveName, createdAt: existed?.createdAt });
|
||||
message.success('查询已保存!');
|
||||
} catch (error) {
|
||||
message.error('保存查询失败: ' + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameQuery = () => {
|
||||
@@ -5384,7 +5388,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const existed = currentSavedQuery || null;
|
||||
const fallbackSavedId = String(tab.savedQueryId || '').trim();
|
||||
const nextSavedId = existed?.id || fallbackSavedId || `saved-${Date.now()}`;
|
||||
persistQuery({
|
||||
await persistQuery({
|
||||
id: nextSavedId,
|
||||
name: String(values.name || '').trim() || '未命名查询',
|
||||
createdAt: existed?.createdAt,
|
||||
@@ -5392,6 +5396,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
message.success(saveModalMode === 'rename' ? '查询已重命名!' : '查询已保存!');
|
||||
setIsSaveModalOpen(false);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
message.error('保存查询失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ interface TreeNode {
|
||||
children?: TreeNode[];
|
||||
icon?: React.ReactNode;
|
||||
dataRef?: any;
|
||||
type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'db-event' | 'routine' | 'object-group' | 'v2-table-section' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring';
|
||||
type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'db-event' | 'routine' | 'object-group' | 'v2-table-section' | 'queries-folder' | 'saved-query' | 'unmatched-saved-queries' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring';
|
||||
}
|
||||
|
||||
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
|
||||
@@ -423,6 +423,11 @@ const Sidebar: React.FC<{
|
||||
const contextMenuPortalRef = useRef<HTMLDivElement | null>(null);
|
||||
const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState<Record<string, V2TableContextMenuStats>>({});
|
||||
const connectionIds = useMemo(() => connections.map((conn) => conn.id), [connections]);
|
||||
const connectionIdSet = useMemo(() => new Set(connectionIds), [connectionIds]);
|
||||
const unmatchedSavedQueries = useMemo(
|
||||
() => savedQueries.filter((query) => query.bindingStatus === 'orphan' || !connectionIdSet.has(query.connectionId)),
|
||||
[connectionIdSet, savedQueries],
|
||||
);
|
||||
const v2RailConnectionGroups = useMemo(
|
||||
() => buildV2RailConnectionGroups(connections, connectionTags, sidebarRootOrder),
|
||||
[connections, connectionTags, sidebarRootOrder],
|
||||
@@ -838,10 +843,28 @@ const Sidebar: React.FC<{
|
||||
|
||||
orderedNodes.push(...Array.from(tagNodesById.values()));
|
||||
orderedNodes.push(...Array.from(ungroupedNodesById.values()));
|
||||
if (unmatchedSavedQueries.length > 0) {
|
||||
orderedNodes.push({
|
||||
title: '未匹配已存查询',
|
||||
key: 'unmatched-saved-queries',
|
||||
icon: <FolderOpenOutlined />,
|
||||
type: 'unmatched-saved-queries',
|
||||
isLeaf: false,
|
||||
selectable: false,
|
||||
children: unmatchedSavedQueries.map((query) => ({
|
||||
title: query.name,
|
||||
key: query.id,
|
||||
icon: <FileTextOutlined />,
|
||||
type: 'saved-query',
|
||||
dataRef: query,
|
||||
isLeaf: true,
|
||||
})),
|
||||
});
|
||||
}
|
||||
const externalSQLRootNode = prev.find((node) => node.type === 'external-sql-root');
|
||||
return externalSQLRootNode ? [...orderedNodes, externalSQLRootNode] : orderedNodes;
|
||||
});
|
||||
}, [connections, connectionTags, sidebarRootOrder]);
|
||||
}, [connections, connectionTags, sidebarRootOrder, unmatchedSavedQueries]);
|
||||
|
||||
const handleDuplicateConnection = async (conn: SavedConnection) => {
|
||||
if (!conn?.id) return;
|
||||
@@ -2505,7 +2528,7 @@ const Sidebar: React.FC<{
|
||||
}, []);
|
||||
|
||||
const onLoadData = async ({ key, children, dataRef, type }: any) => {
|
||||
if (type === 'tag') return;
|
||||
if (type === 'tag' || type === 'unmatched-saved-queries') return;
|
||||
if (hasSidebarLazyChildren(children)) return;
|
||||
|
||||
if (type === 'connection') {
|
||||
@@ -4642,7 +4665,7 @@ const Sidebar: React.FC<{
|
||||
return;
|
||||
}
|
||||
|
||||
saveQuery({
|
||||
const persisted = await saveQuery({
|
||||
...renameSavedQueryTarget,
|
||||
name: nextName,
|
||||
});
|
||||
@@ -4651,8 +4674,8 @@ const Sidebar: React.FC<{
|
||||
if (node.key === renameSavedQueryTarget.id) {
|
||||
return {
|
||||
...node,
|
||||
title: nextName,
|
||||
dataRef: { ...(node.dataRef || renameSavedQueryTarget), name: nextName },
|
||||
title: persisted.name,
|
||||
dataRef: { ...(node.dataRef || renameSavedQueryTarget), ...persisted },
|
||||
};
|
||||
}
|
||||
return node.children ? { ...node, children: updateSavedQueryNode(node.children) } : node;
|
||||
@@ -4662,16 +4685,51 @@ const Sidebar: React.FC<{
|
||||
setTreeData(nextTreeData);
|
||||
tabs
|
||||
.filter(tab => tab.type === 'query' && (tab.savedQueryId === renameSavedQueryTarget.id || tab.id === renameSavedQueryTarget.id))
|
||||
.forEach(tab => updateQueryTabDraft(tab.id, { title: nextName }));
|
||||
.forEach(tab => updateQueryTabDraft(tab.id, { title: persisted.name }));
|
||||
message.success('查询已重命名');
|
||||
setIsRenameSavedQueryModalOpen(false);
|
||||
setRenameSavedQueryTarget(null);
|
||||
renameSavedQueryForm.resetFields();
|
||||
} catch (e) {
|
||||
// Validate failed
|
||||
if (e instanceof Error) {
|
||||
message.error('重命名查询失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isSavedQueryUnmatched = useCallback((query: SavedQuery): boolean => {
|
||||
return query.bindingStatus === 'orphan' || !connectionIdSet.has(query.connectionId);
|
||||
}, [connectionIdSet]);
|
||||
|
||||
const handleRebindSavedQuery = useCallback(async (query: SavedQuery, target: SavedConnection) => {
|
||||
if (!query?.id || !target?.id) return;
|
||||
try {
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
let persisted: SavedQuery;
|
||||
if (typeof backendApp?.RebindSavedQuery === 'function') {
|
||||
persisted = await backendApp.RebindSavedQuery(query.id, target.id);
|
||||
await saveQuery(persisted);
|
||||
} else {
|
||||
persisted = await saveQuery({
|
||||
...query,
|
||||
connectionId: target.id,
|
||||
originalConnectionId: query.originalConnectionId || query.connectionId,
|
||||
bindingStatus: 'active',
|
||||
});
|
||||
}
|
||||
message.success(`查询已绑定到 ${target.name || target.id}`);
|
||||
tabs
|
||||
.filter(tab => tab.type === 'query' && (tab.savedQueryId === query.id || tab.id === query.id))
|
||||
.forEach(tab => updateQueryTabDraft(tab.id, {
|
||||
title: persisted.name,
|
||||
connectionId: persisted.connectionId,
|
||||
dbName: persisted.dbName,
|
||||
}));
|
||||
} catch (error) {
|
||||
message.error('绑定查询失败: ' + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
}, [saveQuery, tabs, updateQueryTabDraft]);
|
||||
|
||||
// --- 函数/存储过程操作 ---
|
||||
const openRoutineDefinition = (node: any) => {
|
||||
const { routineName, routineType, dbName, id } = node.dataRef;
|
||||
@@ -7436,7 +7494,24 @@ const Sidebar: React.FC<{
|
||||
|
||||
// 已存查询节点的右键菜单
|
||||
if (node.type === 'saved-query') {
|
||||
const q = node.dataRef;
|
||||
const q = node.dataRef as SavedQuery;
|
||||
const rebindMenuItems: MenuProps['items'] = isSavedQueryUnmatched(q)
|
||||
? [
|
||||
{
|
||||
key: 'rebind-query',
|
||||
label: '绑定到连接',
|
||||
icon: <LinkOutlined />,
|
||||
disabled: connections.length === 0,
|
||||
children: connections.length > 0
|
||||
? connections.map((conn) => ({
|
||||
key: `rebind-query-${conn.id}`,
|
||||
label: conn.name || conn.id,
|
||||
onClick: () => void handleRebindSavedQuery(q, conn),
|
||||
}))
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return [
|
||||
{
|
||||
key: 'open-query',
|
||||
@@ -7454,6 +7529,7 @@ const Sidebar: React.FC<{
|
||||
});
|
||||
}
|
||||
},
|
||||
...rebindMenuItems,
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'rename-query',
|
||||
@@ -7471,8 +7547,13 @@ const Sidebar: React.FC<{
|
||||
title: '确认删除',
|
||||
content: `确定要删除已保存的查询 "${q.name}" 吗?此操作不可恢复。`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => {
|
||||
deleteQuery(q.id);
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteQuery(q.id);
|
||||
} catch (e) {
|
||||
message.error('删除查询失败: ' + (e instanceof Error ? e.message : String(e)));
|
||||
throw e;
|
||||
}
|
||||
// 从树中移除节点
|
||||
const removeNode = (list: TreeNode[]): TreeNode[] =>
|
||||
list
|
||||
|
||||
@@ -21,6 +21,7 @@ export type SidebarTreeNodeType =
|
||||
| 'v2-table-section'
|
||||
| 'queries-folder'
|
||||
| 'saved-query'
|
||||
| 'unmatched-saved-queries'
|
||||
| 'external-sql-root'
|
||||
| 'external-sql-directory'
|
||||
| 'external-sql-folder'
|
||||
|
||||
Reference in New Issue
Block a user