feat: multi-tab support in MainArea
- Refactor MainArea: tabs array + activeTabId (was single activeTab) - Open/close tabs dynamically, status tab not closable - Tab opener buttons (keys/cli/slowlog/memory) in top-right - Right-click context menu: close/closeOthers/closeRight/closeLeft - Mouse wheel cycling through tabs - Scrollable tab bar with hidden scrollbar - i18n keys for tab context menu (zh-CN + en)
This commit is contained in:
+276
-52
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// src/components/MainArea.vue
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, computed, nextTick } from 'vue'
|
||||
import { useConnectionStore } from '@renderer/stores/connection'
|
||||
import { useI18n } from '@renderer/i18n'
|
||||
import StatusView from './StatusView.vue'
|
||||
@@ -16,70 +16,165 @@ const { t } = useI18n()
|
||||
const props = defineProps<{ showSettings: boolean }>()
|
||||
const emit = defineEmits<{ 'update:showSettings': [v: boolean] }>()
|
||||
|
||||
type Tab = 'status' | 'keys' | 'cli' | 'slowlog' | 'memory'
|
||||
const activeTab = ref<Tab>('status')
|
||||
interface Tab {
|
||||
id: string
|
||||
type: 'status' | 'keys' | 'cli' | 'slowlog' | 'memory'
|
||||
title: string
|
||||
icon: string
|
||||
closable: boolean
|
||||
}
|
||||
|
||||
const tabs = ref<Tab[]>([])
|
||||
const activeTabId = ref<string>('')
|
||||
const tabBarRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value) ?? null)
|
||||
|
||||
function makeTab(type: Tab['type']): Tab {
|
||||
const titles: Record<Tab['type'], string> = {
|
||||
status: t('status.title'),
|
||||
keys: t('key.title'),
|
||||
cli: t('cli.title'),
|
||||
slowlog: t('slowlog.title'),
|
||||
memory: t('memory.title'),
|
||||
}
|
||||
const icons: Record<Tab['type'], string> = {
|
||||
status: 'Monitor',
|
||||
keys: 'Document',
|
||||
cli: 'Monitor',
|
||||
slowlog: 'Clock',
|
||||
memory: 'Odometer',
|
||||
}
|
||||
return {
|
||||
id: `${type}_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
type,
|
||||
title: titles[type],
|
||||
icon: icons[type],
|
||||
closable: type !== 'status',
|
||||
}
|
||||
}
|
||||
|
||||
function openTab(type: Tab['type']) {
|
||||
// For single-instance types, switch to existing if open
|
||||
const existing = tabs.value.find(t => t.type === type)
|
||||
if (existing) {
|
||||
activeTabId.value = existing.id
|
||||
return
|
||||
}
|
||||
const tab = makeTab(type)
|
||||
tabs.value.push(tab)
|
||||
activeTabId.value = tab.id
|
||||
// Scroll to end
|
||||
nextTick(() => {
|
||||
if (tabBarRef.value) {
|
||||
tabBarRef.value.scrollLeft = tabBarRef.value.scrollWidth
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeTab(id: string) {
|
||||
const idx = tabs.value.findIndex(t => t.id === id)
|
||||
if (idx < 0) return
|
||||
tabs.value.splice(idx, 1)
|
||||
if (activeTabId.value === id) {
|
||||
if (tabs.value.length === 0) {
|
||||
activeTabId.value = ''
|
||||
} else {
|
||||
const newIdx = Math.min(idx, tabs.value.length - 1)
|
||||
activeTabId.value = tabs.value[newIdx].id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeOthers(id: string) {
|
||||
tabs.value = tabs.value.filter(t => t.id === id || !t.closable)
|
||||
activeTabId.value = id
|
||||
}
|
||||
|
||||
function closeRight(id: string) {
|
||||
const idx = tabs.value.findIndex(t => t.id === id)
|
||||
tabs.value = tabs.value.filter((t, i) => i <= idx || !t.closable)
|
||||
if (!tabs.value.find(t => t.id === activeTabId.value)) {
|
||||
activeTabId.value = id
|
||||
}
|
||||
}
|
||||
|
||||
function closeLeft(id: string) {
|
||||
const idx = tabs.value.findIndex(t => t.id === id)
|
||||
tabs.value = tabs.value.filter((t, i) => i >= idx || !t.closable)
|
||||
if (!tabs.value.find(t => t.id === activeTabId.value)) {
|
||||
activeTabId.value = id
|
||||
}
|
||||
}
|
||||
|
||||
// Right-click context menu
|
||||
const contextMenu = ref<{ visible: boolean; x: number; y: number; tabId: string }>({
|
||||
visible: false, x: 0, y: 0, tabId: '',
|
||||
})
|
||||
|
||||
function onTabContextMenu(e: MouseEvent, tab: Tab) {
|
||||
if (!tab.closable) return
|
||||
e.preventDefault()
|
||||
contextMenu.value = { visible: true, x: e.clientX, y: e.clientY, tabId: tab.id }
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu.value.visible = false
|
||||
}
|
||||
|
||||
// Mouse wheel cycling
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
if (tabs.value.length < 2) return
|
||||
const idx = tabs.value.findIndex(t => t.id === activeTabId.value)
|
||||
if (idx < 0) return
|
||||
const dir = e.deltaY > 0 ? 1 : -1
|
||||
const nextIdx = (idx + dir + tabs.value.length) % tabs.value.length
|
||||
activeTabId.value = tabs.value[nextIdx].id
|
||||
}
|
||||
|
||||
// Reset tabs when connection state changes
|
||||
watch(
|
||||
() => connStore.isConnected,
|
||||
(connected) => {
|
||||
if (connected) {
|
||||
activeTab.value = 'status'
|
||||
tabs.value = [makeTab('status')]
|
||||
activeTabId.value = tabs.value[0].id
|
||||
} else {
|
||||
tabs.value = []
|
||||
activeTabId.value = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="main-area">
|
||||
<div class="tab-bar" v-if="connStore.isConnected">
|
||||
<button
|
||||
<div class="main-area" @click="closeContextMenu">
|
||||
<div class="tab-bar" v-if="connStore.isConnected" ref="tabBarRef" @wheel.passive="onWheel">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'status' }"
|
||||
@click="activeTab = 'status'"
|
||||
:class="{ active: activeTabId === tab.id }"
|
||||
@click="activeTabId = tab.id"
|
||||
@contextmenu="onTabContextMenu($event, tab)"
|
||||
>
|
||||
<el-icon><Monitor /></el-icon>
|
||||
{{ t('status.title') }}
|
||||
</button>
|
||||
<button
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'keys' }"
|
||||
@click="activeTab = 'keys'"
|
||||
>
|
||||
<el-icon><Document /></el-icon>
|
||||
{{ t('key.title') }}
|
||||
</button>
|
||||
<button
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'cli' }"
|
||||
@click="activeTab = 'cli'"
|
||||
>
|
||||
<el-icon><Monitor /></el-icon>
|
||||
{{ t('cli.title') }}
|
||||
</button>
|
||||
<button
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'slowlog' }"
|
||||
@click="activeTab = 'slowlog'"
|
||||
>
|
||||
<el-icon><Clock /></el-icon>
|
||||
{{ t('slowlog.title') }}
|
||||
</button>
|
||||
<button
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'memory' }"
|
||||
@click="activeTab = 'memory'"
|
||||
>
|
||||
<el-icon><Odometer /></el-icon>
|
||||
{{ t('memory.title') }}
|
||||
</button>
|
||||
<el-icon><component :is="tab.icon" /></el-icon>
|
||||
<span class="tab-title">{{ tab.title }}</span>
|
||||
<span
|
||||
v-if="tab.closable"
|
||||
class="tab-close"
|
||||
@click.stop="closeTab(tab.id)"
|
||||
>×</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<template v-if="connStore.isConnected">
|
||||
<StatusView v-if="activeTab === 'status'" />
|
||||
<KeyBrowser v-else-if="activeTab === 'keys'" />
|
||||
<CliView v-else-if="activeTab === 'cli'" />
|
||||
<SlowLogView v-else-if="activeTab === 'slowlog'" />
|
||||
<MemoryAnalysis v-else-if="activeTab === 'memory'" />
|
||||
<template v-if="connStore.isConnected && activeTab">
|
||||
<StatusView v-if="activeTab.type === 'status'" />
|
||||
<KeyBrowser v-else-if="activeTab.type === 'keys'" />
|
||||
<CliView v-else-if="activeTab.type === 'cli'" />
|
||||
<SlowLogView v-else-if="activeTab.type === 'slowlog'" />
|
||||
<MemoryAnalysis v-else-if="activeTab.type === 'memory'" />
|
||||
</template>
|
||||
<div v-if="!connStore.isConnected" class="no-connection">
|
||||
<div class="placeholder-icon">JRD</div>
|
||||
@@ -88,6 +183,43 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab open buttons (when connected) -->
|
||||
<div class="tab-opener" v-if="connStore.isConnected">
|
||||
<button class="opener-btn" @click="openTab('keys')" :title="t('key.title')">
|
||||
<el-icon><Document /></el-icon>
|
||||
</button>
|
||||
<button class="opener-btn" @click="openTab('cli')" :title="t('cli.title')">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</button>
|
||||
<button class="opener-btn" @click="openTab('slowlog')" :title="t('slowlog.title')">
|
||||
<el-icon><Clock /></el-icon>
|
||||
</button>
|
||||
<button class="opener-btn" @click="openTab('memory')" :title="t('memory.title')">
|
||||
<el-icon><Odometer /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right-click context menu -->
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
class="context-menu"
|
||||
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<div class="context-item" @click="closeTab(contextMenu.tabId); closeContextMenu()">
|
||||
{{ t('tab.close') }}
|
||||
</div>
|
||||
<div class="context-item" @click="closeOthers(contextMenu.tabId); closeContextMenu()">
|
||||
{{ t('tab.closeOthers') }}
|
||||
</div>
|
||||
<div class="context-item" @click="closeRight(contextMenu.tabId); closeContextMenu()">
|
||||
{{ t('tab.closeRight') }}
|
||||
</div>
|
||||
<div class="context-item" @click="closeLeft(contextMenu.tabId); closeContextMenu()">
|
||||
{{ t('tab.closeLeft') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Drawer -->
|
||||
<el-drawer
|
||||
:model-value="showSettings"
|
||||
@@ -108,6 +240,7 @@ watch(
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
@@ -116,10 +249,16 @@ watch(
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tab-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
padding: 10px 16px;
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
@@ -130,16 +269,45 @@ watch(
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: var(--accent-light);
|
||||
border-bottom-color: var(--accent-light);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
@@ -147,6 +315,62 @@ watch(
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-opener {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-left: 1px solid var(--border-color);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.opener-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.opener-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent-light);
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-md);
|
||||
min-width: 140px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.context-item {
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent-light);
|
||||
}
|
||||
|
||||
.no-connection {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -20,6 +20,12 @@ export default {
|
||||
subtitle: 'A modern Redis desktop manager',
|
||||
placeholder: 'Select or create a connection to get started',
|
||||
},
|
||||
tab: {
|
||||
close: 'Close',
|
||||
closeOthers: 'Close Others',
|
||||
closeRight: 'Close to the Right',
|
||||
closeLeft: 'Close to the Left',
|
||||
},
|
||||
connection: {
|
||||
title: 'Connections',
|
||||
new: 'New Connection',
|
||||
|
||||
@@ -20,6 +20,12 @@ export default {
|
||||
subtitle: '现代 Redis 桌面管理器',
|
||||
placeholder: '选择或创建连接开始使用',
|
||||
},
|
||||
tab: {
|
||||
close: '关闭',
|
||||
closeOthers: '关闭其他',
|
||||
closeRight: '关闭右侧',
|
||||
closeLeft: '关闭左侧',
|
||||
},
|
||||
connection: {
|
||||
title: '连接',
|
||||
new: '新建连接',
|
||||
|
||||
Reference in New Issue
Block a user