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:
+1
-1
@@ -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 命令流 + 停止按钮
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user