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] **命令日志** — 记录每次命令的耗时/连接/时间,最多 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 命令流 + 停止按钮
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user