feat: multi-connection parallel support

- connection store: connectedIds array, per-connection health/serverInfo maps
- connect() no longer disconnects existing connections
- New switchTo(id), disconnectById(id), isConnectedById(id) methods
- Per-connection health check timers (Map-based)
- ConnectionCard: switch to connected, connect if not, disconnect if active
- MainArea: per-connection tab state (save/restore on switch)
- Connection switcher dropdown in tab bar showing all open connections
- Clean up tab state when connection disconnects
This commit is contained in:
2026-07-07 20:31:57 +08:00
parent 11a73494c0
commit 6a1f94cd48
4 changed files with 185 additions and 33 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
- [x] **命令日志** — 记录每次命令的耗时/连接/时间,最多 5000 条,只写模式过滤 - [x] **命令日志** — 记录每次命令的耗时/连接/时间,最多 5000 条,只写模式过滤
- [x] **自动更新** — electron-updater 集成,启动检查,下载进度,发行说明 - [x] **自动更新** — electron-updater 集成,启动检查,下载进度,发行说明
- [x] **多标签页** — 单连接支持同时打开 Status/Keys/CLI/SlowLog/内存分析/批量删除 多个标签 - [x] **多标签页** — 单连接支持同时打开 Status/Keys/CLI/SlowLog/内存分析/批量删除 多个标签
- [ ] **多连接并行** — 同时打开多个连接,各自独立标签页 - [x] **多连接并行** — 同时打开多个连接,各自独立标签页
- [ ] **Monaco 编辑器** — 替换 StringEditor 的 textarea,支持 JSON 折叠/展开/语法高亮 - [ ] **Monaco 编辑器** — 替换 StringEditor 的 textarea,支持 JSON 折叠/展开/语法高亮
- [ ] **Redis 命令自动补全** — CLI 输入时智能提示(基于 commands.js 命令数据库) - [ ] **Redis 命令自动补全** — CLI 输入时智能提示(基于 commands.js 命令数据库)
- [ ] **CLI SUBSCRIBE/MONITOR** — 实时消息流 + MONITOR 命令流 + 停止按钮 - [ ] **CLI SUBSCRIBE/MONITOR** — 实时消息流 + MONITOR 命令流 + 停止按钮
+8 -3
View File
@@ -9,14 +9,19 @@ const emit = defineEmits<{ edit: [conn: ConnectionConfig]; delete: [id: string]
const connStore = useConnectionStore() const connStore = useConnectionStore()
const isActive = computed(() => connStore.activeId === props.connection.id) const isActive = computed(() => connStore.activeId === props.connection.id)
const isConn = computed(() => connStore.isConnectedById(props.connection.id))
const statusColor = computed(() => { const statusColor = computed(() => {
if (isActive.value) return 'var(--success)' if (isConn.value) return 'var(--success)'
return 'var(--text-muted)' return 'var(--text-muted)'
}) })
function handleClick() { function handleClick() {
if (isActive.value) { if (isConn.value) {
connStore.disconnect() if (isActive.value) {
connStore.disconnect()
} else {
connStore.switchTo(props.connection.id)
}
} else { } else {
connStore.connect(props.connection) connStore.connect(props.connection)
} }
+118 -6
View File
@@ -133,24 +133,89 @@ function onWheel(e: WheelEvent) {
activeTabId.value = tabs.value[nextIdx].id activeTabId.value = tabs.value[nextIdx].id
} }
// Reset tabs when connection state changes // Per-connection tab state
const tabStateMap = new Map<string, { tabs: Tab[]; activeTabId: string }>()
function saveTabState() {
if (connStore.activeId) {
tabStateMap.set(connStore.activeId, {
tabs: tabs.value,
activeTabId: activeTabId.value,
})
}
}
function restoreTabState(connId: string) {
const state = tabStateMap.get(connId)
if (state) {
tabs.value = state.tabs
activeTabId.value = state.activeTabId
} else {
tabs.value = [makeTab('status')]
activeTabId.value = tabs.value[0].id
}
}
// Connected connections for switcher
const connectedConnections = computed(() =>
connStore.connections.filter(c => connStore.connectedIds.includes(c.id))
)
// Switch active connection, save/restore tabs
watch( watch(
() => connStore.isConnected, () => connStore.activeId,
(connected) => { (newId, oldId) => {
if (connected) { if (oldId) saveTabState()
tabs.value = [makeTab('status')] if (newId) {
activeTabId.value = tabs.value[0].id restoreTabState(newId)
} else { } else {
tabs.value = [] tabs.value = []
activeTabId.value = '' activeTabId.value = ''
} }
} }
) )
// Clean up tab state when connection disconnected
watch(
() => connStore.connectedIds,
(ids) => {
// Remove tab state for disconnected connections
for (const key of tabStateMap.keys()) {
if (!ids.includes(key)) {
tabStateMap.delete(key)
}
}
},
{ deep: true }
)
</script> </script>
<template> <template>
<div class="main-area" @click="closeContextMenu"> <div class="main-area" @click="closeContextMenu">
<div class="tab-bar" v-if="connStore.isConnected" ref="tabBarRef" @wheel.passive="onWheel"> <div class="tab-bar" v-if="connStore.isConnected" ref="tabBarRef" @wheel.passive="onWheel">
<!-- Connection switcher -->
<el-dropdown v-if="connectedConnections.length > 0" trigger="click" @command="connStore.switchTo($event)">
<div class="conn-switcher">
<span class="conn-dot" />
<span class="conn-name">{{ connStore.activeConnection?.name }}</span>
<el-icon v-if="connectedConnections.length > 1"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="conn in connectedConnections"
:key="conn.id"
:command="conn.id"
:class="{ 'is-active': conn.id === connStore.activeId }"
>
{{ conn.name }}
<span class="conn-host">{{ conn.host }}:{{ conn.port }}</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<div class="tab-divider" v-if="connectedConnections.length > 0" />
<!-- Tabs -->
<div <div
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.id" :key="tab.id"
@@ -243,6 +308,53 @@ watch(
position: relative; position: relative;
} }
.conn-switcher {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 12px;
font-weight: 600;
color: var(--accent-light);
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.conn-switcher:hover {
background: var(--bg-hover);
}
.conn-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 6px var(--success);
flex-shrink: 0;
}
.conn-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.conn-host {
font-size: 10px;
color: var(--text-muted);
margin-left: 8px;
}
.tab-divider {
width: 1px;
height: 20px;
background: var(--border-color);
flex-shrink: 0;
align-self: center;
margin: 0 4px;
}
.tab-bar { .tab-bar {
display: flex; display: flex;
gap: 0; gap: 0;
+58 -23
View File
@@ -36,16 +36,23 @@ export interface ConnectionConfig {
export const useConnectionStore = defineStore('connection', () => { export const useConnectionStore = defineStore('connection', () => {
const connections = ref<ConnectionConfig[]>([]) const connections = ref<ConnectionConfig[]>([])
const activeId = ref<string | null>(null) const activeId = ref<string | null>(null)
const connectedIds = ref<string[]>([])
const connecting = ref(false) const connecting = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const serverInfo = ref<string | null>(null) const serverInfoMap = ref<Record<string, string>>({})
const healthOk = ref(true) const healthMap = ref<Record<string, boolean>>({})
let healthTimer: ReturnType<typeof setInterval> | null = null const healthTimers = new Map<string, ReturnType<typeof setInterval>>()
const activeConnection = computed(() => const activeConnection = computed(() =>
connections.value.find((c: ConnectionConfig) => c.id === activeId.value) ?? null connections.value.find((c: ConnectionConfig) => c.id === activeId.value) ?? null
) )
const isConnected = computed(() => activeId.value !== null) const isConnected = computed(() => activeId.value !== null && connectedIds.value.includes(activeId.value))
const serverInfo = computed(() => activeId.value ? serverInfoMap.value[activeId.value] ?? null : null)
const healthOk = computed(() => activeId.value ? healthMap.value[activeId.value] ?? false : false)
function isConnectedById(id: string): boolean {
return connectedIds.value.includes(id)
}
async function loadConnections() { async function loadConnections() {
connections.value = await window.electronAPI.storage.getConnections() connections.value = await window.electronAPI.storage.getConnections()
@@ -66,7 +73,7 @@ export const useConnectionStore = defineStore('connection', () => {
} }
async function deleteConnection(id: string) { async function deleteConnection(id: string) {
if (activeId.value === id) await disconnect() if (connectedIds.value.includes(id)) await disconnectById(id)
connections.value = await window.electronAPI.storage.deleteConnection(id) connections.value = await window.electronAPI.storage.deleteConnection(id)
} }
@@ -75,6 +82,11 @@ export const useConnectionStore = defineStore('connection', () => {
} }
async function connect(conn: ConnectionConfig) { async function connect(conn: ConnectionConfig) {
// If already connected, just switch
if (connectedIds.value.includes(conn.id)) {
activeId.value = conn.id
return
}
connecting.value = true connecting.value = true
error.value = null error.value = null
try { try {
@@ -107,8 +119,9 @@ export const useConnectionStore = defineStore('connection', () => {
sshPassphrase: conn.sshPassphrase, sshPassphrase: conn.sshPassphrase,
}) })
if (result.success) { if (result.success) {
connectedIds.value.push(conn.id)
activeId.value = conn.id activeId.value = conn.id
serverInfo.value = await window.electronAPI.redis.getInfo(conn.id) serverInfoMap.value[conn.id] = await window.electronAPI.redis.getInfo(conn.id)
startHealthCheck(conn.id) startHealthCheck(conn.id)
} else { } else {
error.value = result.error || 'Connection failed' error.value = result.error || 'Connection failed'
@@ -120,12 +133,26 @@ export const useConnectionStore = defineStore('connection', () => {
} }
} }
function switchTo(id: string) {
if (connectedIds.value.includes(id)) {
activeId.value = id
}
}
async function disconnectById(id: string) {
stopHealthCheck(id)
await window.electronAPI.redis.disconnect(id)
connectedIds.value = connectedIds.value.filter(i => i !== id)
delete serverInfoMap.value[id]
delete healthMap.value[id]
if (activeId.value === id) {
activeId.value = connectedIds.value[0] ?? null
}
}
async function disconnect() { async function disconnect() {
stopHealthCheck()
if (activeId.value) { if (activeId.value) {
await window.electronAPI.redis.disconnect(activeId.value) await disconnectById(activeId.value)
activeId.value = null
serverInfo.value = null
} }
} }
@@ -133,7 +160,7 @@ export const useConnectionStore = defineStore('connection', () => {
if (!activeId.value) return '' if (!activeId.value) return ''
try { try {
const info = await window.electronAPI.redis.getInfo(activeId.value) const info = await window.electronAPI.redis.getInfo(activeId.value)
serverInfo.value = info serverInfoMap.value[activeId.value] = info
return info return info
} catch { } catch {
return '' return ''
@@ -141,26 +168,34 @@ export const useConnectionStore = defineStore('connection', () => {
} }
function startHealthCheck(connId: string) { function startHealthCheck(connId: string) {
stopHealthCheck() stopHealthCheck(connId)
healthOk.value = true healthMap.value[connId] = true
healthTimer = setInterval(async () => { const timer = setInterval(async () => {
try { try {
const ok = await window.electronAPI.redis.isConnected(connId) const ok = await window.electronAPI.redis.isConnected(connId)
healthOk.value = ok healthMap.value[connId] = ok
if (!ok && activeId.value === connId) { if (!ok && activeId.value === connId) {
// Connection lost
error.value = 'Connection lost' error.value = 'Connection lost'
} }
} catch { } catch {
healthOk.value = false healthMap.value[connId] = false
} }
}, 5000) }, 5000)
healthTimers.set(connId, timer)
} }
function stopHealthCheck() { function stopHealthCheck(connId?: string) {
if (healthTimer) { if (connId) {
clearInterval(healthTimer) const timer = healthTimers.get(connId)
healthTimer = null if (timer) {
clearInterval(timer)
healthTimers.delete(connId)
}
} else {
for (const [, timer] of healthTimers) {
clearInterval(timer)
}
healthTimers.clear()
} }
} }
@@ -238,9 +273,9 @@ export const useConnectionStore = defineStore('connection', () => {
} }
return { return {
connections, activeId, connecting, error, serverInfo, healthOk, connections, activeId, connectedIds, connecting, error, serverInfo, healthOk,
activeConnection, isConnected, activeConnection, isConnected,
loadConnections, decryptPassword, getInfo, saveConnection, deleteConnection, reorderConnections, loadConnections, decryptPassword, getInfo, saveConnection, deleteConnection, reorderConnections,
connect, disconnect, testConnection, exportConnections, importConnections, connect, disconnect, disconnectById, switchTo, isConnectedById, testConnection, exportConnections, importConnections,
} }
}) })