Feature/add aibook (#160)

* feat: 增加技术圈连接

* feat: 增强水平滚动条在大量数据下支持鼠标滚轮 (refs #146)

* feat: 新增连接标签分组功能,支持创建/编辑/删除标签、拖拽归组、右键移至标签 refs #148
This commit is contained in:
凌封
2026-03-04 11:50:34 +08:00
committed by GitHub
parent f2fc7cbd05
commit 8c91d8929b
6 changed files with 390 additions and 82 deletions

View File

@@ -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<string>('');
const [lastUpdateInfo, setLastUpdateInfo] = useState<UpdateInfo | null>(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<number | null>(null);
const ghostRef = React.useRef<HTMLDivElement>(null);
@@ -1221,6 +1221,9 @@ function App() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div>{aboutInfo?.version || '未知'}</div>
<div>{aboutInfo?.author || '未知'}</div>
{aboutInfo?.communityUrl ? (
<div><a onClick={(e) => { e.preventDefault(); if (aboutInfo?.communityUrl) BrowserOpenURL(aboutInfo.communityUrl); }} href={aboutInfo.communityUrl}>AI全书</a></div>
) : null}
<div>{aboutUpdateStatus || '未检查'}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<GithubOutlined />

View File

@@ -2705,29 +2705,31 @@ const DataGrid: React.FC<DataGridProps> = ({
horizontalSyncSourceRef.current = '';
}, []);
const handleExternalHorizontalWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
// 非虚拟模式:外部水平滚动条的 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<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
}
};
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<DataGridProps> = ({
className="data-grid-external-hscroll"
aria-hidden={!horizontalScrollVisible}
onScroll={applyExternalScrollToTableTargets}
onWheel={handleExternalHorizontalWheel}
style={{
opacity: horizontalScrollVisible ? 1 : 0,
pointerEvents: horizontalScrollVisible ? 'auto' : 'none',

View File

@@ -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<any>(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<BatchObjectItem[]>([]);
@@ -208,11 +219,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
useEffect(() => {
setTreeData((prev) => {
const prevMap = new Map<string, TreeNode>();
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<string>();
const tagNodes: TreeNode[] = connectionTags.map((tag) => {
tag.connectionIds.forEach(id => taggedConnIds.add(id));
return {
title: tag.name,
key: `tag-${tag.id}`,
icon: <FolderOutlined style={{ color: '#faad14' }} />,
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: <EditOutlined />,
onClick: () => {
createTagForm.setFieldsValue({ name: node.title, connectionIds: node.dataRef.connectionIds });
setRenameViewTarget(node);
setIsCreateTagModalOpen(true);
}
},
{ type: 'divider' },
{
key: 'delete-tag',
label: '删除标签',
icon: <DeleteOutlined />,
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: <FolderOutlined />,
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: <FolderOpenOutlined />,
children: tagSubMenuItems
},
{
key: 'disconnect',
label: '断开连接',
@@ -2741,6 +2840,72 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return <span title={hoverTitle}>{statusBadge}{displayTitle}</span>;
};
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 }>
<Search placeholder="搜索..." onChange={onSearch} size="small" />
</div>
{/* Toolbar for batch operations - always visible */}
<div style={{ padding: '4px 8px', borderBottom: 'none', display: 'flex', gap: 4 }}>
{/* Toolbar */}
<div style={{ padding: '4px 8px', borderBottom: 'none', display: 'flex', flexWrap: 'wrap', gap: 4 }}>
<Button
size="small"
icon={<FolderOpenOutlined />}
onClick={() => {
setRenameViewTarget(null); // Create mode
createTagForm.resetFields();
setIsCreateTagModalOpen(true);
}}
style={{ flex: '1 1 auto' }}
>
</Button>
<Button
size="small"
icon={<CheckSquareOutlined />}
onClick={() => openBatchOperationModal()}
style={{ flex: 1 }}
style={{ flex: '1 1 auto' }}
>
</Button>
@@ -2772,7 +2949,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
size="small"
icon={<CheckSquareOutlined />}
onClick={() => openBatchDatabaseModal()}
style={{ flex: 1 }}
style={{ flex: '1 1 auto' }}
>
</Button>
@@ -2781,6 +2958,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
<div ref={treeContainerRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<Tree
showIcon
draggable={{
icon: false,
nodeDraggable: (node: any) => 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 }>
</Dropdown>
)}
<Modal
title={renameViewTarget?.type === 'tag' ? "编辑标签" : "新建组"}
open={isCreateTagModalOpen}
onOk={() => {
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)}
>
<Form form={createTagForm} layout="vertical">
<Form.Item name="name" label="标签名称" rules={[{ required: true, message: '请输入标签名称' }]}>
<Input />
</Form.Item>
<Form.Item name="connectionIds" label="选择连接">
<Checkbox.Group style={{ width: '100%' }}>
<Space direction="vertical" style={{ width: '100%', maxHeight: '400px', overflowY: 'auto' }}>
{connections.map(conn => (
<Checkbox key={conn.id} value={conn.id}>
{conn.name} {conn.config.host ? `(${conn.config.host})` : ''}
</Checkbox>
))}
</Space>
</Checkbox.Group>
</Form.Item>
</Form>
</Modal>
<Modal
title="新建数据库"
open={isCreateDbModalOpen}

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery } from './types';
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag } from './types';
const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 };
const DEFAULT_UI_SCALE = 1.0;
@@ -17,7 +17,7 @@ const MAX_HOST_ENTRY_LENGTH = 512;
const MAX_HOST_ENTRIES = 64;
const DEFAULT_TIMEOUT_SECONDS = 30;
const MAX_TIMEOUT_SECONDS = 3600;
const PERSIST_VERSION = 4;
const PERSIST_VERSION = 5;
const DEFAULT_CONNECTION_TYPE = 'mysql';
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
enabled: false,
@@ -293,6 +293,27 @@ const sanitizeConnections = (value: unknown): SavedConnection[] => {
return result;
};
const sanitizeConnectionTags = (value: unknown): ConnectionTag[] => {
if (!Array.isArray(value)) return [];
const result: ConnectionTag[] = [];
const idSet = new Set<string>();
value.forEach((entry, index) => {
if (!entry || typeof entry !== 'object') return;
const raw = entry as Record<string, unknown>;
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<AppState>()(
persist(
(set) => ({
connections: [],
connectionTags: [],
tabs: [],
activeTabId: null,
activeContext: null,
@@ -516,7 +545,46 @@ export const useStore = create<AppState>()(
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<AppState>()(
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
const nextState: Partial<AppState> = { ...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<AppState>()(
...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<AppState>()(
},
partialize: (state) => ({
connections: state.connections,
connectionTags: state.connectionTags,
savedQueries: state.savedQueries,
theme: state.theme,
appearance: state.appearance,

View File

@@ -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;