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:
2026-07-07 20:26:47 +08:00
parent bb41ea78e6
commit 11a73494c0
4 changed files with 289 additions and 53 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
- [x] **内存分析**`MEMORY USAGE` 扫描所有 key,虚拟化列表,按大小排序,暂停/恢复,大小过滤
- [x] **命令日志** — 记录每次命令的耗时/连接/时间,最多 5000 条,只写模式过滤
- [x] **自动更新** — electron-updater 集成,启动检查,下载进度,发行说明
- [ ] **多标签页** — 单连接支持同时打开 Status/Keys/CLI/SlowLog/内存分析/批量删除 多个标签
- [x] **多标签页** — 单连接支持同时打开 Status/Keys/CLI/SlowLog/内存分析/批量删除 多个标签
- [ ] **多连接并行** — 同时打开多个连接,各自独立标签页
- [ ] **Monaco 编辑器** — 替换 StringEditor 的 textarea,支持 JSON 折叠/展开/语法高亮
- [ ] **Redis 命令自动补全** — CLI 输入时智能提示(基于 commands.js 命令数据库)
+276 -52
View File
@@ -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;
+6
View File
@@ -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',
+6
View File
@@ -20,6 +20,12 @@ export default {
subtitle: '现代 Redis 桌面管理器',
placeholder: '选择或创建连接开始使用',
},
tab: {
close: '关闭',
closeOthers: '关闭其他',
closeRight: '关闭右侧',
closeLeft: '关闭左侧',
},
connection: {
title: '连接',
new: '新建连接',