diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ba93b57..18092e2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd'; import zhCN from 'antd/locale/zh_CN'; -import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons'; +import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined } from '@ant-design/icons'; import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowToggleMaximise } from '../wailsjs/runtime'; import Sidebar from './components/Sidebar'; import TabManager from './components/TabManager'; @@ -299,7 +299,7 @@ function App() { const [isAboutOpen, setIsAboutOpen] = useState(false); const isAboutOpenRef = React.useRef(false); const [aboutLoading, setAboutLoading] = useState(false); - const [aboutInfo, setAboutInfo] = useState<{ version: string; author: string; buildTime?: string; repoUrl?: string; issueUrl?: string; releaseUrl?: string } | null>(null); + const [aboutInfo, setAboutInfo] = useState<{ version: string; author: string; buildTime?: string; repoUrl?: string; issueUrl?: string; releaseUrl?: string; communityUrl?: string } | null>(null); const [aboutUpdateStatus, setAboutUpdateStatus] = useState(''); const [lastUpdateInfo, setLastUpdateInfo] = useState(null); const [updateDownloadProgress, setUpdateDownloadProgress] = useState<{ @@ -803,7 +803,7 @@ function App() { }; // Sidebar Resizing - const [sidebarWidth, setSidebarWidth] = useState(300); + const [sidebarWidth, setSidebarWidth] = useState(330); const sidebarDragRef = React.useRef<{ startX: number, startWidth: number } | null>(null); const rafRef = React.useRef(null); const ghostRef = React.useRef(null); @@ -1221,6 +1221,9 @@ function App() {
版本:{aboutInfo?.version || '未知'}
作者:{aboutInfo?.author || '未知'}
+ {aboutInfo?.communityUrl ? ( + + ) : null}
更新状态:{aboutUpdateStatus || '未检查'}
diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 05ccbba..3e4fb90 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -2705,29 +2705,31 @@ const DataGrid: React.FC = ({ horizontalSyncSourceRef.current = ''; }, []); - const handleExternalHorizontalWheel = useCallback((event: React.WheelEvent) => { + // 非虚拟模式:外部水平滚动条的 wheel 处理(通过原生事件绑定,确保 preventDefault 生效) + useEffect(() => { const externalScroll = externalHScrollRef.current; - if (!(externalScroll instanceof HTMLDivElement)) { - return; - } - const dominantDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; - if (!Number.isFinite(dominantDelta) || Math.abs(dominantDelta) < 0.5) { - return; - } + if (!externalScroll || !horizontalScrollVisible) return; - const maxScrollLeft = Math.max(0, externalScroll.scrollWidth - externalScroll.clientWidth); - if (maxScrollLeft <= 0) { - return; - } + const handleExternalWheel = (e: WheelEvent) => { + // 鼠标在水平滚动条区域时,始终阻止垂直滚动冒泡 + e.preventDefault(); + e.stopPropagation(); - const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, externalScroll.scrollLeft + dominantDelta)); - if (Math.abs(nextScrollLeft - externalScroll.scrollLeft) < 0.5) { - return; - } + const dominantDelta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + if (!Number.isFinite(dominantDelta) || Math.abs(dominantDelta) < 0.5) return; - event.preventDefault(); - externalScroll.scrollLeft = nextScrollLeft; - }, []); + const maxScrollLeft = Math.max(0, externalScroll.scrollWidth - externalScroll.clientWidth); + if (maxScrollLeft <= 0) return; + + const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, externalScroll.scrollLeft + dominantDelta)); + externalScroll.scrollLeft = nextScrollLeft; + }; + + externalScroll.addEventListener('wheel', handleExternalWheel, { passive: false, capture: true }); + return () => { + externalScroll.removeEventListener('wheel', handleExternalWheel, { capture: true } as EventListenerOptions); + }; + }, [horizontalScrollVisible]); useEffect(() => { if (viewMode !== 'table') return; @@ -2735,19 +2737,24 @@ const DataGrid: React.FC = ({ return () => cancelAnimationFrame(rafId); }, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]); - // 虚拟模式下,为 rc-virtual-list 的内置水平滚动条添加鼠标滚轮支持 - // rc-virtual-list 的 ScrollBar 组件原生只支持拖拽,不支持 wheel 事件 - // 方案:使用 MutationObserver 发现滚动条元素后直接绑定 wheel 事件 + // 虚拟模式下,在容器级别监听 wheel 事件,当鼠标在底部水平滚动条区域时拦截并转为水平滚动 useEffect(() => { if (viewMode !== 'table' || !enableVirtual) return; const container = tableContainerRef.current; if (!container) return; - let currentScrollbarEl: HTMLElement | null = null; + // 滚动条区域高度:滚动条高度 + 间距 + 容错 + const scrollbarZoneHeight = floatingScrollbarHeight + floatingScrollbarGap + 8; - const handleScrollbarWheel = (e: WheelEvent) => { - const innerEl = container.querySelector('.rc-virtual-list-holder-inner') as HTMLElement | null; - const holderEl = container.querySelector('.rc-virtual-list-holder') as HTMLElement | null; + const handleContainerWheel = (e: WheelEvent) => { + // 判断鼠标是否在底部滚动条区域 + const containerRect = container.getBoundingClientRect(); + if (e.clientY < containerRect.bottom - scrollbarZoneHeight) return; + + // 适配 antd 的虚拟列表类名 + const holderEl = container.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null; + const innerEl = holderEl?.querySelector('.ant-table-tbody-virtual-holder-inner') as HTMLElement | null; + if (!innerEl || !holderEl) return; const dominantDelta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; @@ -2769,12 +2776,13 @@ const DataGrid: React.FC = ({ innerEl.style.marginLeft = `${-newOffset}px`; // 同步 scrollbar thumb 位置 - if (currentScrollbarEl && maxScroll > 0) { - const thumbEl = currentScrollbarEl.querySelector('[class*="scrollbar-thumb"]') as HTMLElement | null; + const scrollbarEl = container.querySelector('.ant-table-tbody-virtual-scrollbar-horizontal') as HTMLElement | null; + if (scrollbarEl && maxScroll > 0) { + const thumbEl = scrollbarEl.querySelector('[class*="scrollbar-thumb"]') as HTMLElement | null; if (thumbEl) { const ratio = newOffset / maxScroll; const thumbWidth = parseFloat(thumbEl.style.width) || thumbEl.offsetWidth; - const trackWidth = currentScrollbarEl.clientWidth; + const trackWidth = scrollbarEl.clientWidth; const thumbMaxOffset = trackWidth - thumbWidth; thumbEl.style.left = `${ratio * thumbMaxOffset}px`; } @@ -2787,33 +2795,12 @@ const DataGrid: React.FC = ({ } }; - const bindScrollbar = () => { - const el = container.querySelector('.ant-table-tbody-virtual-scrollbar-horizontal') as HTMLElement | null; - if (el && el !== currentScrollbarEl) { - if (currentScrollbarEl) { - currentScrollbarEl.removeEventListener('wheel', handleScrollbarWheel); - } - currentScrollbarEl = el; - el.addEventListener('wheel', handleScrollbarWheel, { passive: false }); - } - }; - - // 初次尝试绑定 - bindScrollbar(); - - // 使用 MutationObserver 监听 DOM 变化,确保即使元素延迟渲染也能绑定 - const observer = new MutationObserver(() => { - bindScrollbar(); - }); - observer.observe(container, { childList: true, subtree: true }); + container.addEventListener('wheel', handleContainerWheel, { passive: false, capture: true }); return () => { - observer.disconnect(); - if (currentScrollbarEl) { - currentScrollbarEl.removeEventListener('wheel', handleScrollbarWheel); - } + container.removeEventListener('wheel', handleContainerWheel, { capture: true } as EventListenerOptions); }; - }, [viewMode, enableVirtual, tableScrollX, mergedDisplayData.length]); + }, [viewMode, enableVirtual, tableScrollX, floatingScrollbarHeight, floatingScrollbarGap]); useEffect(() => { if (viewMode !== 'table') return; @@ -3307,7 +3294,6 @@ const DataGrid: React.FC = ({ className="data-grid-external-hscroll" aria-hidden={!horizontalScrollVisible} onScroll={applyExternalScrollToTableTargets} - onWheel={handleExternalHorizontalWheel} style={{ opacity: horizontalScrollVisible ? 1 : 0, pointerEvents: horizontalScrollVisible ? 'auto' : 'none', diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9b5cf28..981dc28 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, EyeOutlined, ConsoleSqlOutlined, HddOutlined, + FolderOutlined, FolderOpenOutlined, FileTextOutlined, CopyOutlined, @@ -42,7 +43,7 @@ interface TreeNode { children?: TreeNode[]; icon?: React.ReactNode; dataRef?: any; - type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db'; + type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag'; } type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly'; @@ -64,6 +65,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const addTab = useStore(state => state.addTab); const setActiveContext = useStore(state => state.setActiveContext); const removeConnection = useStore(state => state.removeConnection); + const connectionTags = useStore(state => state.connectionTags); + const addConnectionTag = useStore(state => state.addConnectionTag); + const updateConnectionTag = useStore(state => state.updateConnectionTag); + const removeConnectionTag = useStore(state => state.removeConnectionTag); + const moveConnectionToTag = useStore(state => state.moveConnectionToTag); + const reorderTags = useStore(state => state.reorderTags); const closeTabsByConnection = useStore(state => state.closeTabsByConnection); const closeTabsByDatabase = useStore(state => state.closeTabsByDatabase); const theme = useStore(state => state.theme); @@ -127,6 +134,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const [renameViewForm] = Form.useForm(); const [renameViewTarget, setRenameViewTarget] = useState(null); + // Connection Tag Modals + const [isCreateTagModalOpen, setIsCreateTagModalOpen] = useState(false); + const [createTagForm] = Form.useForm(); + // Batch Operations Modal const [isBatchModalOpen, setIsBatchModalOpen] = useState(false); const [batchTables, setBatchTables] = useState([]); @@ -208,11 +219,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> useEffect(() => { setTreeData((prev) => { const prevMap = new Map(); - prev.forEach((node) => { - prevMap.set(String(node.key), node); - }); + + // We need to recursively extract connections from old tag structures + // so if a user expands a connection that was tagged, the state remains + const recurseCollect = (nodes: TreeNode[]) => { + nodes.forEach((node) => { + if (node.type === 'tag') { + if (node.children) recurseCollect(node.children); + } else if (node.type === 'connection') { + prevMap.set(String(node.key), node); + } + }); + }; + recurseCollect(prev); - return connections.map((conn) => { + const buildConnectionNode = (conn: SavedConnection): TreeNode => { const existing = prevMap.get(conn.id); return { title: conn.name, @@ -223,9 +244,32 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> isLeaf: false, children: existing?.children, } as TreeNode; + }; + + const taggedConnIds = new Set(); + const tagNodes: TreeNode[] = connectionTags.map((tag) => { + tag.connectionIds.forEach(id => taggedConnIds.add(id)); + return { + title: tag.name, + key: `tag-${tag.id}`, + icon: , + type: 'tag', + dataRef: tag, + isLeaf: false, + children: tag.connectionIds + .map(cid => connections.find(c => c.id === cid)) + .filter(Boolean) + .map(conn => buildConnectionNode(conn!)), + } as TreeNode; }); + + const ungroupedNodes: TreeNode[] = connections + .filter(c => !taggedConnIds.has(c.id)) + .map(conn => buildConnectionNode(conn)); + + return [...tagNodes, ...ungroupedNodes]; }); - }, [connections]); + }, [connections, connectionTags]); const updateTreeData = (list: TreeNode[], key: React.Key, children: TreeNode[] | undefined): TreeNode[] => { return list.map(node => { @@ -1042,6 +1086,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }; const onLoadData = async ({ key, children, dataRef, type }: any) => { + if (type === 'tag') return; if (children) return; if (type === 'connection') { @@ -2284,6 +2329,38 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return routineMenu; } + // Connection Tag Menu — must be BEFORE the connection check + if (node.type === 'tag') { + return [ + { + key: 'edit-tag', + label: '编辑标签', + icon: , + onClick: () => { + createTagForm.setFieldsValue({ name: node.title, connectionIds: node.dataRef.connectionIds }); + setRenameViewTarget(node); + setIsCreateTagModalOpen(true); + } + }, + { type: 'divider' }, + { + key: 'delete-tag', + label: '删除标签', + icon: , + danger: true, + onClick: () => { + Modal.confirm({ + title: '确认删除', + content: `确定要删除标签 "${node.title}" 吗?这不会删除里面的连接。`, + onOk: () => { + removeConnectionTag(node.dataRef.id); + } + }); + } + } + ]; + } + if (node.type === 'connection') { // Redis connection menu if (isRedis) { @@ -2358,6 +2435,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> ]; } + // Tag submenu for connection + const tagSubMenuItems: MenuProps['items'] = connectionTags.map(tag => ({ + key: `move-to-tag-${tag.id}`, + label: tag.name, + icon: , + onClick: () => moveConnectionToTag(node.key, tag.id) + })); + if (connectionTags.length > 0) { + tagSubMenuItems.push({ type: 'divider' }); + } + tagSubMenuItems.push({ + key: 'move-to-ungrouped', + label: '移出标签', + onClick: () => moveConnectionToTag(node.key, null) + }); + // Regular database connection menu return [ { @@ -2400,6 +2493,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> if (onEditConnection) onEditConnection(node.dataRef); } }, + { + key: 'move-to-tag', + label: '移至标签', + icon: , + children: tagSubMenuItems + }, { key: 'disconnect', label: '断开连接', @@ -2741,6 +2840,72 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return {statusBadge}{displayTitle}; }; + const handleDrop = (info: any) => { + const dropKey = info.node.key; + const dragKey = info.dragNode.key; + const dropPos = info.node.pos.split('-'); + const dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]); + + const dragNode = info.dragNode; + const dropNode = info.node; + + // Tag to Tag reordering + if (dragNode.type === 'tag') { + // You can only drop tags onto the root level (before/after other tags or connections at root) + if (dropNode.type === 'tag' || dropNode.type === 'connection') { + // Get current order + const currentTagOrder = connectionTags.map(t => t.id); + const dragTagId = dragNode.dataRef.id; + + // Filter out the dragging tag + const newOrder = currentTagOrder.filter(id => id !== dragTagId); + + let insertIndex = newOrder.length; + if (dropNode.type === 'tag') { + const dropTagId = dropNode.dataRef.id; + const dropIndex = newOrder.indexOf(dropTagId); + + if (dropPosition === -1) { + insertIndex = dropIndex; + } else { + insertIndex = dropIndex + 1; + } + } else { + // Dropped onto a root connection, usually meaning moving to the end of tags + // Since tags are always displayed before ungrouped connections, just put it at the end + insertIndex = newOrder.length; + } + + newOrder.splice(insertIndex, 0, dragTagId); + reorderTags(newOrder); + } + return; + } + + // Connection moving to tag (any drop position on a tag node counts as "into") + if (dragNode.type === 'connection' && dropNode.type === 'tag') { + moveConnectionToTag(dragNode.key, dropNode.dataRef.id); + return; + } + + // Connection moving to another connection inside a tag + if (dragNode.type === 'connection' && dropNode.type === 'connection') { + // Find if drop target is under a tag + const targetTag = connectionTags.find(t => t.connectionIds.includes(dropNode.key)); + if (targetTag) { + moveConnectionToTag(dragNode.key, targetTag.id); + return; + } + + // Drop target is NOT under a tag (ungrouped) -> move OUT of tag + const sourceTag = connectionTags.find(t => t.connectionIds.includes(dragNode.key)); + if (sourceTag) { + moveConnectionToTag(dragNode.key, null); + return; + } + } + }; + const onRightClick = ({ event, node }: any) => { const items = getNodeMenuItems(node); if (items && items.length > 0) { @@ -2758,13 +2923,25 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
- {/* Toolbar for batch operations - always visible */} -
+ {/* Toolbar */} +
+ @@ -2772,7 +2949,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> size="small" icon={} onClick={() => openBatchDatabaseModal()} - style={{ flex: 1 }} + style={{ flex: '1 1 auto' }} > 批量操作库 @@ -2781,6 +2958,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
node.type === 'connection' || node.type === 'tag' + }} + onDrop={handleDrop} loadData={onLoadData} treeData={displayTreeData} onDoubleClick={onDoubleClick} @@ -2809,6 +2991,60 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> )} + { + createTagForm.validateFields().then(values => { + if (renameViewTarget?.type === 'tag') { + // Rename + updateConnectionTag({ + ...renameViewTarget.dataRef, + name: values.name, + connectionIds: values.connectionIds || [] + }); + // update cross-connections + const allOtherTagsIds = connectionTags.filter(t => t.id !== renameViewTarget.dataRef.id).flatMap(t => t.connectionIds); + (values.connectionIds || []).forEach((cid: string) => { + if (allOtherTagsIds.includes(cid)) { + moveConnectionToTag(cid, renameViewTarget.dataRef.id); + } + }); + } else { + // Create + const tagId = Date.now().toString(); + addConnectionTag({ + id: tagId, + name: values.name, + connectionIds: values.connectionIds || [] + }); + (values.connectionIds || []).forEach((cid: string) => { + moveConnectionToTag(cid, tagId); + }); + } + setIsCreateTagModalOpen(false); + }); + }} + onCancel={() => setIsCreateTagModalOpen(false)} + > +
+ + + + + + + {connections.map(conn => ( + + {conn.name} {conn.config.host ? `(${conn.config.host})` : ''} + + ))} + + + +
+
+ { return result; }; +const sanitizeConnectionTags = (value: unknown): ConnectionTag[] => { + if (!Array.isArray(value)) return []; + const result: ConnectionTag[] = []; + const idSet = new Set(); + + value.forEach((entry, index) => { + if (!entry || typeof entry !== 'object') return; + const raw = entry as Record; + const id = toTrimmedString(raw.id, `tag-${index + 1}`) || `tag-${index + 1}`; + if (idSet.has(id)) return; + idSet.add(id); + + const name = toTrimmedString(raw.name, `标签-${index + 1}`) || `标签-${index + 1}`; + const connectionIds = sanitizeStringArray(raw.connectionIds, 256); + + result.push({ id, name, connectionIds }); + }); + + return result; +}; + const isLegacyDefaultAppearance = (appearance: Partial<{ opacity: number; blur: number }> | undefined): boolean => { if (!appearance) { return true; @@ -325,6 +346,7 @@ export interface GlobalProxyConfig extends ProxyConfig { interface AppState { connections: SavedConnection[]; + connectionTags: ConnectionTag[]; tabs: TabData[]; activeTabId: string | null; activeContext: { connectionId: string; dbName: string } | null; @@ -345,6 +367,12 @@ interface AppState { updateConnection: (conn: SavedConnection) => void; removeConnection: (id: string) => void; + addConnectionTag: (tag: ConnectionTag) => void; + updateConnectionTag: (tag: ConnectionTag) => void; + removeConnectionTag: (id: string) => void; + moveConnectionToTag: (connectionId: string, targetTagId: string | null) => void; + reorderTags: (tagIds: string[]) => void; + addTab: (tab: TabData) => void; closeTab: (id: string) => void; closeOtherTabs: (id: string) => void; @@ -496,6 +524,7 @@ export const useStore = create()( persist( (set) => ({ connections: [], + connectionTags: [], tabs: [], activeTabId: null, activeContext: null, @@ -516,7 +545,46 @@ export const useStore = create()( updateConnection: (conn) => set((state) => ({ connections: state.connections.map(c => c.id === conn.id ? conn : c) })), - removeConnection: (id) => set((state) => ({ connections: state.connections.filter(c => c.id !== id) })), + removeConnection: (id) => set((state) => ({ + connections: state.connections.filter(c => c.id !== id), + connectionTags: state.connectionTags.map(tag => ({ + ...tag, + connectionIds: tag.connectionIds.filter(cid => cid !== id) + })) + })), + + addConnectionTag: (tag) => set((state) => ({ connectionTags: [...state.connectionTags, tag] })), + updateConnectionTag: (tag) => set((state) => ({ + connectionTags: state.connectionTags.map(t => t.id === tag.id ? tag : t) + })), + removeConnectionTag: (id) => set((state) => ({ + connectionTags: state.connectionTags.filter(t => t.id !== id) + })), + moveConnectionToTag: (connectionId, targetTagId) => set((state) => { + const newTags = state.connectionTags.map(tag => { + //先从所有tag中移除该connection + const filteredIds = tag.connectionIds.filter(id => id !== connectionId); + if (tag.id === targetTagId) { + return { ...tag, connectionIds: [...filteredIds, connectionId] }; + } + return { ...tag, connectionIds: filteredIds }; + }); + return { connectionTags: newTags }; + }), + reorderTags: (tagIds) => set((state) => { + const tagMap = new Map(state.connectionTags.map(t => [t.id, t])); + const newTags: ConnectionTag[] = []; + tagIds.forEach(id => { + const tag = tagMap.get(id); + if (tag) { + newTags.push(tag); + tagMap.delete(id); + } + }); + // 追加未指定的tag(如果有的话) + newTags.push(...Array.from(tagMap.values())); + return { connectionTags: newTags }; + }), addTab: (tab) => set((state) => { const index = state.tabs.findIndex(t => t.id === tab.id); @@ -672,6 +740,11 @@ export const useStore = create()( const state = unwrapPersistedAppState(persistedState) as Partial; const nextState: Partial = { ...state }; nextState.connections = sanitizeConnections(state.connections); + if (version < 5) { + nextState.connectionTags = sanitizeConnectionTags(state.connectionTags); + } else { + nextState.connectionTags = sanitizeConnectionTags(state.connectionTags); + } nextState.savedQueries = sanitizeSavedQueries(state.savedQueries); nextState.theme = sanitizeTheme(state.theme); nextState.appearance = sanitizeAppearance(state.appearance, version); @@ -691,6 +764,7 @@ export const useStore = create()( ...currentState, ...state, connections: sanitizeConnections(state.connections), + connectionTags: sanitizeConnectionTags(state.connectionTags), savedQueries: sanitizeSavedQueries(state.savedQueries), theme: sanitizeTheme(state.theme), appearance: sanitizeAppearance(state.appearance, PERSIST_VERSION), @@ -706,6 +780,7 @@ export const useStore = create()( }, partialize: (state) => ({ connections: state.connections, + connectionTags: state.connectionTags, savedQueries: state.savedQueries, theme: state.theme, appearance: state.appearance, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2bc8dac..d0cb31b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -61,6 +61,12 @@ export interface SavedConnection { includeRedisDatabases?: number[]; // Redis databases to show (0-15) } +export interface ConnectionTag { + id: string; + name: string; + connectionIds: string[]; +} + export interface ColumnDefinition { name: string; type: string; diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index bd98ace..ae2bdfe 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -51,12 +51,13 @@ type UpdateInfo struct { } type AppInfo struct { - Version string `json:"version"` - Author string `json:"author"` - RepoURL string `json:"repoUrl,omitempty"` - IssueURL string `json:"issueUrl,omitempty"` - ReleaseURL string `json:"releaseUrl,omitempty"` - BuildTime string `json:"buildTime,omitempty"` + Version string `json:"version"` + Author string `json:"author"` + RepoURL string `json:"repoUrl,omitempty"` + IssueURL string `json:"issueUrl,omitempty"` + ReleaseURL string `json:"releaseUrl,omitempty"` + CommunityURL string `json:"communityUrl,omitempty"` + BuildTime string `json:"buildTime,omitempty"` } type updateDownloadResult struct { @@ -137,12 +138,13 @@ func (a *App) CheckForUpdates() connection.QueryResult { func (a *App) GetAppInfo() connection.QueryResult { info := AppInfo{ - Version: getCurrentVersion(), - Author: getCurrentAuthor(), - RepoURL: "https://github.com/" + updateRepo, - IssueURL: "https://github.com/" + updateRepo + "/issues", - ReleaseURL: "https://github.com/" + updateRepo + "/releases", - BuildTime: strings.TrimSpace(AppBuildTime), + Version: getCurrentVersion(), + Author: getCurrentAuthor(), + RepoURL: "https://github.com/" + updateRepo, + IssueURL: "https://github.com/" + updateRepo + "/issues", + ReleaseURL: "https://github.com/" + updateRepo + "/releases", + CommunityURL: "https://aibook.ren", + BuildTime: strings.TrimSpace(AppBuildTime), } return connection.QueryResult{Success: true, Message: "OK", Data: info} }