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] **自动更新** — electron-updater 集成,启动检查,下载进度,发行说明
- [x] **多标签页** — 单连接支持同时打开 Status/Keys/CLI/SlowLog/内存分析/批量删除 多个标签
- [ ] **多连接并行** — 同时打开多个连接,各自独立标签页
- [x] **多连接并行** — 同时打开多个连接,各自独立标签页
- [ ] **Monaco 编辑器** — 替换 StringEditor 的 textarea,支持 JSON 折叠/展开/语法高亮
- [ ] **Redis 命令自动补全** — CLI 输入时智能提示(基于 commands.js 命令数据库)
- [ ] **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 isActive = computed(() => connStore.activeId === props.connection.id)
const isConn = computed(() => connStore.isConnectedById(props.connection.id))
const statusColor = computed(() => {
if (isActive.value) return 'var(--success)'
if (isConn.value) return 'var(--success)'
return 'var(--text-muted)'
})
function handleClick() {
if (isActive.value) {
connStore.disconnect()
if (isConn.value) {
if (isActive.value) {
connStore.disconnect()
} else {
connStore.switchTo(props.connection.id)
}
} else {
connStore.connect(props.connection)
}
+118 -6
View File
@@ -133,24 +133,89 @@ function onWheel(e: WheelEvent) {
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(
() => connStore.isConnected,
(connected) => {
if (connected) {
tabs.value = [makeTab('status')]
activeTabId.value = tabs.value[0].id
() => connStore.activeId,
(newId, oldId) => {
if (oldId) saveTabState()
if (newId) {
restoreTabState(newId)
} else {
tabs.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>
<template>
<div class="main-area" @click="closeContextMenu">
<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
v-for="tab in tabs"
:key="tab.id"
@@ -243,6 +308,53 @@ watch(
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 {
display: flex;
gap: 0;
+58 -23
View File
@@ -36,16 +36,23 @@ export interface ConnectionConfig {
export const useConnectionStore = defineStore('connection', () => {
const connections = ref<ConnectionConfig[]>([])
const activeId = ref<string | null>(null)
const connectedIds = ref<string[]>([])
const connecting = ref(false)
const error = ref<string | null>(null)
const serverInfo = ref<string | null>(null)
const healthOk = ref(true)
let healthTimer: ReturnType<typeof setInterval> | null = null
const serverInfoMap = ref<Record<string, string>>({})
const healthMap = ref<Record<string, boolean>>({})
const healthTimers = new Map<string, ReturnType<typeof setInterval>>()
const activeConnection = computed(() =>
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() {
connections.value = await window.electronAPI.storage.getConnections()
@@ -66,7 +73,7 @@ export const useConnectionStore = defineStore('connection', () => {
}
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)
}
@@ -75,6 +82,11 @@ export const useConnectionStore = defineStore('connection', () => {
}
async function connect(conn: ConnectionConfig) {
// If already connected, just switch
if (connectedIds.value.includes(conn.id)) {
activeId.value = conn.id
return
}
connecting.value = true
error.value = null
try {
@@ -107,8 +119,9 @@ export const useConnectionStore = defineStore('connection', () => {
sshPassphrase: conn.sshPassphrase,
})
if (result.success) {
connectedIds.value.push(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)
} else {
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() {
stopHealthCheck()
if (activeId.value) {
await window.electronAPI.redis.disconnect(activeId.value)
activeId.value = null
serverInfo.value = null
await disconnectById(activeId.value)
}
}
@@ -133,7 +160,7 @@ export const useConnectionStore = defineStore('connection', () => {
if (!activeId.value) return ''
try {
const info = await window.electronAPI.redis.getInfo(activeId.value)
serverInfo.value = info
serverInfoMap.value[activeId.value] = info
return info
} catch {
return ''
@@ -141,26 +168,34 @@ export const useConnectionStore = defineStore('connection', () => {
}
function startHealthCheck(connId: string) {
stopHealthCheck()
healthOk.value = true
healthTimer = setInterval(async () => {
stopHealthCheck(connId)
healthMap.value[connId] = true
const timer = setInterval(async () => {
try {
const ok = await window.electronAPI.redis.isConnected(connId)
healthOk.value = ok
healthMap.value[connId] = ok
if (!ok && activeId.value === connId) {
// Connection lost
error.value = 'Connection lost'
}
} catch {
healthOk.value = false
healthMap.value[connId] = false
}
}, 5000)
healthTimers.set(connId, timer)
}
function stopHealthCheck() {
if (healthTimer) {
clearInterval(healthTimer)
healthTimer = null
function stopHealthCheck(connId?: string) {
if (connId) {
const timer = healthTimers.get(connId)
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 {
connections, activeId, connecting, error, serverInfo, healthOk,
connections, activeId, connectedIds, connecting, error, serverInfo, healthOk,
activeConnection, isConnected,
loadConnections, decryptPassword, getInfo, saveConnection, deleteConnection, reorderConnections,
connect, disconnect, testConnection, exportConnections, importConnections,
connect, disconnect, disconnectById, switchTo, isConnectedById, testConnection, exportConnections, importConnections,
}
})