feat: P3 batch 2 - sidebar resize, DB features, Status/SlowLog, editor improvements

- Resizable sidebar (200-500px, localStorage persistence)
- DB auto-detection (CONFIG GET databases) + key count in dropdown
- DB memory (last selected DB per connection)
- Status auto-refresh toggle + search filter + key statistics table
- SlowLog config display (threshold + max len)
- Large value protection (>20MB placeholder + download)
- Hash field TTL (HEXPIRE support detection)
- Editor inline filters (Hash/Set/Zset)
- Zset ASC/DESC sort toggle
- CLI auto-sync SELECT + refresh key list on write commands
- i18n keys for all features (zh-CN + en)
This commit is contained in:
2026-07-07 22:44:59 +08:00
parent 5505434325
commit 75c3a854d6
12 changed files with 533 additions and 45 deletions
+14 -14
View File
@@ -48,26 +48,26 @@
- [x] **连接颜色标记** — 每个连接可选颜色(预定义色板)
- [x] **连接拖拽排序** — Sortable.js 拖拽重排连接列表
- [x] **复制连接** — 右键复制现有连接配置
- [ ] **侧边栏可拖拽** — 调整宽度 200–1500px,持久化
- [x] **侧边栏可拖拽** — 调整宽度 200–1500px,持久化
- [ ] **DB 自定义名称** — 用户可重命名 DB0 → 生产库,下拉显示
- [ ] **DB key 数量显示** — 下拉框显示每个 DB 的 key 数量
- [ ] **DB 数量自动检测** — CONFIG GET databases + INFO keyspace
- [ ] **DB 记忆** — 每个连接记住最后选择的数据库
- [x] **DB key 数量显示** — 下拉框显示每个 DB 的 key 数量
- [x] **DB 数量自动检测** — CONFIG GET databases + INFO keyspace
- [x] **DB 记忆** — 每个连接记住最后选择的数据库
- [ ] **更多语言** — de/es/fr/it/ko/pt/ru/tr/ua/vi/tw(当前仅 en/zh-CN
- [ ] **页面缩放** — 0.5x2.0x 缩放控制
- [ ] **自定义字体** — 用户可选等宽字体
- [ ] **SlowLog 配置显示** — slowlog-log-slower-than / slowlog-max-len
- [x] **SlowLog 配置显示** — slowlog-log-slower-than / slowlog-max-len
- [ ] **Cluster SlowLog** — 每个 master 节点分别查询
- [ ] **Cluster Keyspace 表** — 集群模式显示各节点 key 数量
- [ ] **Status 自动刷新开关** — 可暂停/恢复的刷新(当前固定 1s)
- [ ] **Status 搜索过滤** — INFO 表格搜索
- [ ] **Key 统计表** — 每个 DB 的 Keys/Expires/Avg TTL 排序表
- [ ] **大值保护** — >20MB 显示占位符而非崩溃
- [ ] **Hash 字段 TTL** — Redis 7.4+ HEXPIRE 支持
- [ ] **Editor 行内过滤** — 每个编辑器的表头搜索
- [ ] **Zset 排序切换** — ASC/DESC 单选按钮
- [ ] **CLI 自动同步** — CLI 执行 SELECT 后自动同步 UI
- [ ] **CLI 执行后刷新 key 列表** — set/hset/del 等命令后自动刷新
- [x] **Status 自动刷新开关** — 可暂停/恢复的刷新(当前固定 1s)
- [x] **Status 搜索过滤** — INFO 表格搜索
- [x] **Key 统计表** — 每个 DB 的 Keys/Expires/Avg TTL 排序表
- [x] **大值保护** — >20MB 显示占位符而非崩溃
- [x] **Hash 字段 TTL** — Redis 7.4+ HEXPIRE 支持
- [x] **Editor 行内过滤** — 每个编辑器的表头搜索
- [x] **Zset 排序切换** — ASC/DESC 单选按钮
- [x] **CLI 自动同步** — CLI 执行 SELECT 后自动同步 UI
- [x] **CLI 执行后刷新 key 列表** — set/hset/del 等命令后自动刷新
- [ ] **标签页滚轮切换** — 鼠标滚轮切换标签
- [ ] **标签页右键菜单** — 关闭/关闭其他/关闭左侧/关闭右侧
- [ ] **Element Plus 深色适配** — 所有组件无白底
+65 -8
View File
@@ -1,21 +1,77 @@
<script setup lang="ts">
// src/components/DbSelector.vue
import { ref, watch } from 'vue'
import { ref, watch, computed } from 'vue'
import { useConnectionStore } from '@renderer/stores/connection'
import { useKeyStore } from '@renderer/stores/key'
import { useI18n } from '@renderer/i18n'
const connStore = useConnectionStore()
const keyStore = useKeyStore()
const { t } = useI18n()
const selectedDb = ref(0)
const dbs = Array.from({ length: 16 }, (_, i) => i)
const dbCount = ref(16)
const dbKeyCounts = ref<Record<number, number>>({})
const dbList = computed(() => {
return Array.from({ length: dbCount.value }, (_, i) => {
const count = dbKeyCounts.value[i]
const label = count !== undefined && count > 0
? `DB${i} (${t('db.keyCount', { n: count })})`
: count === 0
? `DB${i} (${t('db.noKeys')})`
: `DB${i}`
return { value: i, label }
})
})
async function fetchDbInfo() {
if (!connStore.activeId) return
try {
// Get actual DB count from CONFIG GET databases
const configResult = await window.electronAPI.redis.execute(
connStore.activeId, 'CONFIG', ['GET', 'databases']
)
if (configResult && configResult[1]) {
dbCount.value = parseInt(configResult[1], 10) || 16
}
} catch {
dbCount.value = 16
}
try {
// Parse INFO keyspace for per-DB key counts
const info = await connStore.getInfo()
const keyspace: Record<number, number> = {}
const match = info.match(/# Keyspace[\s\S]*?(?=\n#|$)/)
if (match) {
const lines = match[0].split('\n')
for (const line of lines) {
const dbMatch = line.match(/^db(\d+):keys=(\d+)/)
if (dbMatch) {
keyspace[parseInt(dbMatch[1], 10)] = parseInt(dbMatch[2], 10)
}
}
}
// Mark DBs with 0 keys explicitly
for (let i = 0; i < dbCount.value; i++) {
if (!(i in keyspace)) {
keyspace[i] = 0
}
}
dbKeyCounts.value = keyspace
} catch {
dbKeyCounts.value = {}
}
}
watch(
() => connStore.isConnected,
(connected) => {
async (connected) => {
if (connected) {
selectedDb.value = 0
keyStore.currentDb = 0
await fetchDbInfo()
}
}
)
@@ -23,6 +79,7 @@ watch(
async function switchDb(db: number) {
if (!connStore.activeId) return
selectedDb.value = db
connStore.saveLastDb(connStore.activeId, db)
await keyStore.selectDb(connStore.activeId, db)
}
</script>
@@ -34,13 +91,13 @@ async function switchDb(db: number) {
:model-value="selectedDb"
size="small"
@change="switchDb"
style="width: 60px"
style="width: 130px"
>
<el-option
v-for="db in dbs"
:key="db"
:label="db"
:value="db"
v-for="db in dbList"
:key="db.value"
:label="db.label"
:value="db.value"
/>
</el-select>
</div>
+93 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// src/components/HashEditor.vue
import { ref, watch } from 'vue'
import { ref, watch, computed, onMounted } from 'vue'
import { useKeyStore } from '@renderer/stores/key'
import { useConnectionStore } from '@renderer/stores/connection'
import { useI18n } from '@renderer/i18n'
@@ -13,6 +13,7 @@ const { t } = useI18n()
interface HashField {
field: string
value: string
ttl?: number | null
}
const fields = ref<HashField[]>([])
@@ -20,12 +21,22 @@ const loading = ref(false)
const showAddDialog = ref(false)
const newFieldName = ref('')
const newFieldValue = ref('')
const filterText = ref('')
const supportsHexpire = ref(false)
const filteredFields = computed(() => {
if (!filterText.value) return fields.value
const lower = filterText.value.toLowerCase()
return fields.value.filter(
f => f.field.toLowerCase().includes(lower) || f.value.toLowerCase().includes(lower)
)
})
watch(
() => keyStore.keyData,
(newData) => {
if (Array.isArray(newData)) {
fields.value = newData.map(([field, value]) => ({ field, value }))
fields.value = newData.map(([field, value]) => ({ field, value, ttl: null }))
}
},
{ immediate: true }
@@ -37,11 +48,62 @@ async function refresh() {
try {
const result = await window.electronAPI.redis.hashScan(connStore.activeId, keyStore.selectedKey, '0', 100)
keyStore.keyData = result.fields
await checkHexpireSupport()
if (supportsHexpire.value) {
await loadFieldTtls()
}
} finally {
loading.value = false
}
}
async function checkHexpireSupport() {
if (!connStore.activeId) return
try {
const result = await window.electronAPI.redis.execute(connStore.activeId, 'COMMAND', ['INFO', 'HEXPIRE'])
supportsHexpire.value = Array.isArray(result) && result.length > 0
} catch {
supportsHexpire.value = false
}
}
async function loadFieldTtls() {
if (!connStore.activeId || !keyStore.selectedKey) return
for (const field of fields.value) {
try {
const ttlResult = await window.electronAPI.redis.execute(connStore.activeId, 'HTTL', [keyStore.selectedKey, 'FIELDS', '1', field.field])
field.ttl = Number(ttlResult)
} catch {
field.ttl = null
}
}
}
async function setFieldTtl(field: HashField) {
if (!connStore.activeId || !keyStore.selectedKey) return
try {
const { value: seconds } = await ElMessageBox.prompt(t('editor.ttlTitle'), t('editor.setFieldTtl'), {
inputValue: '-1',
inputPlaceholder: t('key.ttlHint'),
})
if (seconds === '-1') {
// Remove TTL via PERSIST equivalent — HEXPIRE with 0 or use PERSIST on field
await window.electronAPI.redis.execute(connStore.activeId, 'HEXPIRE', [keyStore.selectedKey, '0', 'FIELDS', '1', field.field])
field.ttl = -1
} else {
await window.electronAPI.redis.execute(connStore.activeId, 'HEXPIRE', [keyStore.selectedKey, seconds, 'FIELDS', '1', field.field])
field.ttl = Number(seconds)
}
ElMessage.success(t('editor.saved'))
} catch (err: any) {
if (err !== 'cancel') ElMessage.error(err.message)
}
}
onMounted(() => {
checkHexpireSupport()
})
async function saveField(item: HashField) {
if (!connStore.activeId || !keyStore.selectedKey) return
try {
@@ -83,12 +145,17 @@ async function addField() {
<div class="hash-editor">
<div class="editor-toolbar">
<span class="type-badge">HASH</span>
<span class="item-count">{{ t('editor.fieldCount', { n: fields.length }) }}</span>
<el-input v-model="filterText" :placeholder="t('editor.filter')" size="small" clearable style="width: 150px" />
<el-button size="small" type="primary" @click="showAddDialog = true">{{ t('editor.addField') }}</el-button>
<el-button size="small" @click="refresh" :loading="loading">{{ t('common.refresh') }}</el-button>
<span v-if="filterText && filteredFields.length !== fields.length" class="filtered-count">
{{ t('editor.filteredCount', { n: filteredFields.length }) }}
</span>
</div>
<div class="hash-table-wrap">
<el-table :data="fields" size="small" class="hash-table" max-height="400">
<el-table :data="filteredFields" size="small" class="hash-table" max-height="400">
<el-table-column prop="field" :label="t('editor.field')" min-width="150">
<template #default="{ row }">
<el-input v-model="row.field" size="small" class="hash-input" />
@@ -99,9 +166,15 @@ async function addField() {
<el-input v-model="row.value" size="small" class="hash-input" />
</template>
</el-table-column>
<el-table-column width="120">
<el-table-column v-if="supportsHexpire" :label="t('editor.fieldTtl')" width="100">
<template #default="{ row }">
<span class="field-ttl">{{ row.ttl != null ? row.ttl : '-' }}</span>
</template>
</el-table-column>
<el-table-column width="180">
<template #default="{ row }">
<el-button size="small" type="primary" text @click="saveField(row)">{{ t('editor.save') }}</el-button>
<el-button v-if="supportsHexpire" size="small" type="warning" text @click="setFieldTtl(row)">{{ t('editor.setFieldTtl') }}</el-button>
<el-button size="small" type="danger" text @click="deleteField(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
@@ -168,4 +241,20 @@ async function addField() {
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 12px;
}
.item-count {
font-size: 11px;
color: var(--text-muted);
}
.filtered-count {
font-size: 11px;
color: var(--accent-light);
}
.field-ttl {
font-size: 12px;
font-family: 'Cascadia Code', monospace;
color: var(--text-muted);
}
</style>
+1 -1
View File
@@ -62,7 +62,7 @@ async function removeMember(member: string) {
const filteredMembers = ref<string[]>([])
watch([members, filterText], () => {
filteredMembers.value = filterText.value
? members.value.filter(m => m.includes(filterText.value))
? members.value.filter(m => m.toLowerCase().includes(filterText.value.toLowerCase()))
: members.value
}, { immediate: true })
</script>
+59 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// src/components/Sidebar.vue
import { ref } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import ConnectionList from './ConnectionList.vue'
import NewConnectionDialog from './NewConnectionDialog.vue'
import CommandLog from './CommandLog.vue'
@@ -19,6 +19,41 @@ const editingConnection = ref<ConnectionConfig | null>(null)
const collapsed = ref(false)
const commandLogVisible = ref(false)
// Resizable sidebar
const sidebarWidth = ref(parseInt(localStorage.getItem('sidebarWidth') || '260', 10))
const isResizing = ref(false)
function startResize(e: MouseEvent) {
e.preventDefault()
isResizing.value = true
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', stopResize)
}
function onResize(e: MouseEvent) {
if (!isResizing.value) return
const newWidth = Math.max(200, Math.min(500, e.clientX))
sidebarWidth.value = newWidth
}
function stopResize() {
if (!isResizing.value) return
isResizing.value = false
localStorage.setItem('sidebarWidth', String(sidebarWidth.value))
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
}
onMounted(() => {
// Ensure width is within bounds after loading from localStorage
sidebarWidth.value = Math.max(200, Math.min(500, sidebarWidth.value))
})
onUnmounted(() => {
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
})
function handleEdit(conn: ConnectionConfig | null) {
editingConnection.value = conn
dialogVisible.value = true
@@ -103,10 +138,11 @@ useKeyboard({
</script>
<template>
<aside class="sidebar" :class="{ collapsed }">
<aside class="sidebar" :class="{ collapsed, resizing: isResizing }" :style="{ width: collapsed ? '36px' : sidebarWidth + 'px' }">
<div class="sidebar-header" v-if="!collapsed">
<ConnectionList @edit="handleEdit" />
</div>
<div class="drag-handle" @mousedown="startResize"></div>
<button class="collapse-btn" @click="collapsed = !collapsed" :title="collapsed ? '展开' : '收缩'">
<el-icon>
<component :is="collapsed ? 'ArrowRight' : 'ArrowLeft'" />
@@ -122,7 +158,6 @@ useKeyboard({
<style scoped>
.sidebar {
width: 260px;
flex-shrink: 0;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
@@ -130,11 +165,15 @@ useKeyboard({
flex-direction: column;
overflow: hidden;
position: relative;
user-select: none;
}
.sidebar:not(.resizing) {
transition: width 0.2s ease;
}
.sidebar.collapsed {
width: 36px;
width: 36px !important;
}
.sidebar-header {
@@ -142,6 +181,22 @@ useKeyboard({
overflow: hidden;
}
.drag-handle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
z-index: 5;
}
.drag-handle:hover,
.sidebar.resizing .drag-handle {
background: var(--accent);
opacity: 0.3;
}
.collapse-btn {
position: absolute;
top: 50%;
+36 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// src/components/SlowLogView.vue
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useConnectionStore } from '@renderer/stores/connection'
import { useI18n } from '@renderer/i18n'
@@ -16,6 +16,20 @@ interface SlowLogEntry {
const entries = ref<SlowLogEntry[]>([])
const loading = ref(false)
const configThreshold = ref<string | null>(null)
const configMaxLen = ref<string | null>(null)
async function fetchConfig() {
if (!connStore.activeId) return
try {
const threshold = await window.electronAPI.redis.execute(connStore.activeId, 'CONFIG', ['GET', 'slowlog-log-slower-than'])
const maxLen = await window.electronAPI.redis.execute(connStore.activeId, 'CONFIG', ['GET', 'slowlog-max-len'])
configThreshold.value = Array.isArray(threshold) && threshold.length >= 2 ? String(threshold[1]) : null
configMaxLen.value = Array.isArray(maxLen) && maxLen.length >= 2 ? String(maxLen[1]) : null
} catch {
// config fetch failed silently
}
}
async function refresh() {
if (!connStore.activeId) return
@@ -28,6 +42,7 @@ async function refresh() {
duration: item[2],
command: Array.isArray(item[3]) ? item[3].join(' ') : String(item[3]),
}))
await fetchConfig()
} finally {
loading.value = false
}
@@ -43,6 +58,12 @@ function formatTime(ts: number) {
return new Date(ts * 1000).toLocaleString()
}
onMounted(() => {
if (connStore.activeId) {
fetchConfig()
}
})
defineExpose({ refresh })
</script>
@@ -67,6 +88,10 @@ defineExpose({ refresh })
</el-table-column>
</el-table>
</div>
<div class="config-footer" v-if="configThreshold !== null || configMaxLen !== null">
<span v-if="configThreshold !== null">{{ t('slowlog.threshold') }}: {{ formatDuration(Number(configThreshold)) }}</span>
<span v-if="configMaxLen !== null">{{ t('slowlog.maxLen') }}: {{ configMaxLen }}</span>
</div>
</div>
</template>
@@ -102,4 +127,14 @@ defineExpose({ refresh })
font-family: 'Cascadia Code', monospace;
font-size: 12px;
}
.config-footer {
display: flex;
gap: 24px;
padding: 8px 12px;
border-top: 1px solid var(--border-color);
font-size: 12px;
color: var(--text-secondary);
flex-shrink: 0;
}
</style>
+142 -7
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// src/components/StatusView.vue
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useConnectionStore } from '@renderer/stores/connection'
import { useI18n } from '@renderer/i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -15,8 +15,73 @@ interface InfoSection {
items: { key: string; value: string }[]
}
interface KeyStatEntry {
db: string
keys: number
expires: number
avgTtl: number | null
}
const sections = ref<InfoSection[]>([])
// Auto-refresh toggle state (persisted to localStorage, default ON)
const autoRefreshEnabled = ref(localStorage.getItem('statusAutoRefresh') !== 'false')
function toggleAutoRefresh() {
autoRefreshEnabled.value = !autoRefreshEnabled.value
localStorage.setItem('statusAutoRefresh', String(autoRefreshEnabled.value))
if (autoRefreshEnabled.value && connStore.isConnected) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
}
// Search filter
const searchQuery = ref('')
const filteredSections = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return sections.value
return sections.value
.map((sec) => {
const titleMatch = sec.title.toLowerCase().includes(q)
const filteredItems = sec.items.filter(
(item) =>
item.key.toLowerCase().includes(q) || item.value.toLowerCase().includes(q)
)
if (titleMatch) {
return { ...sec, items: sec.items }
}
if (filteredItems.length > 0) {
return { ...sec, items: filteredItems }
}
return null
})
.filter((s): s is InfoSection => s !== null)
})
// Key Statistics from Keyspace section
const keyStats = computed<KeyStatEntry[]>(() => {
const keyspaceSection = sections.value.find((s) => s.title === 'Keyspace')
if (!keyspaceSection) return []
return keyspaceSection.items.map((item) => {
const db = item.key
const keys = parseInt(item.value.match(/keys=(\d+)/)?.[1] ?? '0', 10)
const expires = parseInt(item.value.match(/expires=(\d+)/)?.[1] ?? '0', 10)
const avgTtlRaw = item.value.match(/avg_ttl=(\d+)/)?.[1]
const avgTtl = avgTtlRaw ? parseInt(avgTtlRaw, 10) : null
return { db, keys, expires, avgTtl }
})
})
function formatAvgTtl(us: number | null): string {
if (us === null) return '-'
if (us < 1000) return `${us}μs`
if (us < 1000000) return `${(us / 1000).toFixed(1)}ms`
return `${(us / 1000000).toFixed(2)}s`
}
// Redis INFO section name translations
const sectionTranslations: Record<string, string> = {
'Server': 'status.sectionServer',
@@ -73,6 +138,7 @@ async function refresh() {
function startAutoRefresh() {
stopAutoRefresh()
if (!autoRefreshEnabled.value) return
refresh()
refreshTimer = setInterval(refresh, 1000)
}
@@ -132,6 +198,13 @@ defineExpose({ refresh })
<div class="status-actions">
<el-button size="small" type="danger" @click="handleFlushDb">{{ t('status.flushDb') }}</el-button>
<el-button size="small" @click="refresh">{{ t('common.refresh') }}</el-button>
<el-button
size="small"
:type="autoRefreshEnabled ? 'primary' : 'default'"
@click="toggleAutoRefresh"
>
{{ t('status.autoRefresh') }}
</el-button>
</div>
</div>
@@ -146,15 +219,44 @@ defineExpose({ refresh })
</div>
</div>
<!-- Search filter -->
<div class="search-bar">
<el-input
v-model="searchQuery"
:placeholder="t('status.searchInfo')"
size="small"
clearable
prefix-icon="Search"
/>
</div>
<!-- Key Statistics table -->
<div v-if="keyStats.length > 0" class="key-stats-section">
<h3 class="section-title">{{ t('status.keyStats') }}</h3>
<el-table :data="keyStats" size="small" stripe max-height="200">
<el-table-column :label="t('status.dbColumn')" prop="db" width="100" />
<el-table-column :label="t('status.keysColumn')" prop="keys" width="100" />
<el-table-column :label="t('status.expiresColumn')" prop="expires" width="100" />
<el-table-column :label="t('status.avgTtlColumn')" width="120">
<template #default="{ row }">{{ formatAvgTtl(row.avgTtl) }}</template>
</el-table-column>
</el-table>
</div>
<div class="info-sections">
<div v-for="section in sections" :key="section.title" class="info-section">
<h3 class="section-title">{{ translateSectionTitle(section.title) }}</h3>
<div class="section-items">
<div v-for="item in section.items" :key="item.key" class="info-row">
<span class="info-key">{{ item.key }}</span>
<span class="info-val">{{ item.value }}</span>
<template v-if="filteredSections.length > 0">
<div v-for="section in filteredSections" :key="section.title" class="info-section">
<h3 class="section-title">{{ translateSectionTitle(section.title) }}</h3>
<div class="section-items">
<div v-for="item in section.items" :key="item.key" class="info-row">
<span class="info-key">{{ item.key }}</span>
<span class="info-val">{{ item.value }}</span>
</div>
</div>
</div>
</template>
<div v-else class="no-results">
{{ t('status.noResults') }}
</div>
</div>
</div>
@@ -263,4 +365,37 @@ defineExpose({ refresh })
color: var(--text-primary);
font-family: 'Cascadia Code', 'Fira Code', monospace;
}
.search-bar {
margin-bottom: 16px;
}
.key-stats-section {
margin-bottom: 16px;
}
.key-stats-section .section-title {
font-size: 12px;
font-weight: 700;
color: var(--accent-light);
padding: 10px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-bottom: none;
border-radius: var(--radius-md) var(--radius-md) 0 0;
margin: 0;
}
.key-stats-section .el-table {
border: 1px solid var(--border-color);
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
.no-results {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
color: var(--text-muted);
font-size: 13px;
}
</style>
+33
View File
@@ -20,6 +20,20 @@ const isEditing = ref(false)
const selectedFormat = ref('auto')
const detectedFormat = ref('text')
const MAX_DISPLAY_SIZE = 20 * 1024 * 1024 // 20MB
const isValueTooLarge = computed(() => editedValue.value.length > MAX_DISPLAY_SIZE)
function downloadValue() {
const blob = new Blob([editedValue.value], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `value_${keyStore.selectedKey || 'unknown'}.txt`
a.click()
URL.revokeObjectURL(url)
}
const monacoTheme = computed(() => {
if (appStore.theme === 'light') return 'vs'
return 'vs-dark'
@@ -174,7 +188,12 @@ onUnmounted(() => {
</template>
</div>
<div class="editor-content">
<div v-if="isValueTooLarge" class="value-too-large">
<span class="too-large-text">{{ t('editor.valueTooLarge') }}</span>
<el-button size="small" type="primary" @click="downloadValue">{{ t('editor.download') }}</el-button>
</div>
<vue-monaco-editor
v-else
v-model:value="displayValue"
:theme="monacoTheme"
:language="monacoLanguage"
@@ -224,4 +243,18 @@ onUnmounted(() => {
flex: 1;
overflow: hidden;
}
.value-too-large {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 16px;
}
.too-large-text {
font-size: 14px;
color: var(--text-muted);
}
</style>
+17 -4
View File
@@ -21,6 +21,7 @@ const filterText = ref('')
const showAddDialog = ref(false)
const newMember = ref('')
const newScore = ref(0)
const sortOrder = ref<'asc' | 'desc'>('asc')
async function loadMembers() {
if (!connStore.activeId || !keyStore.selectedKey) return
@@ -71,10 +72,18 @@ async function removeMember(member: string) {
}
const filteredItems = ref<ZsetItem[]>([])
watch([items, filterText], () => {
filteredItems.value = filterText.value
? items.value.filter(i => i.member.includes(filterText.value))
: items.value
watch([items, filterText, sortOrder], () => {
let result = items.value
if (filterText.value) {
const lower = filterText.value.toLowerCase()
result = result.filter(i =>
i.member.toLowerCase().includes(lower) || String(i.score).includes(lower)
)
}
result = [...result].sort((a, b) =>
sortOrder.value === 'asc' ? a.score - b.score : b.score - a.score
)
filteredItems.value = result
}, { immediate: true })
</script>
@@ -84,6 +93,10 @@ watch([items, filterText], () => {
<span class="type-badge">ZSET</span>
<span class="item-count">{{ t('editor.members', { n: items.length }) }}</span>
<el-input v-model="filterText" :placeholder="t('editor.filter')" size="small" clearable style="width: 150px" />
<el-radio-group v-model="sortOrder" size="small">
<el-radio-button value="asc">{{ t('editor.sortAsc') }}</el-radio-button>
<el-radio-button value="desc">{{ t('editor.sortDesc') }}</el-radio-button>
</el-radio-group>
<el-button size="small" type="primary" @click="showAddDialog = true">{{ t('common.add') }}</el-button>
<el-button size="small" @click="loadMembers" :loading="loading">{{ t('common.refresh') }}</el-button>
</div>
+20
View File
@@ -179,6 +179,14 @@ export default {
ack: 'ACK',
ackConfirm: 'ACK selected entries?',
noGroups: 'No consumer groups',
valueTooLarge: 'Value too large to display (>20MB)',
download: 'Download',
setFieldTtl: 'Set Field TTL',
fieldTtl: 'Field TTL',
sortAsc: 'Ascending',
sortDesc: 'Descending',
filteredCount: '{n} filtered',
fieldCount: '{n} fields',
},
cli: {
title: 'CLI',
@@ -205,6 +213,8 @@ export default {
time: 'Time',
duration: 'Duration',
command: 'Command',
threshold: 'Threshold',
maxLen: 'Max Length',
},
memory: {
title: 'Memory Analysis',
@@ -255,6 +265,14 @@ export default {
connecting: 'Connecting...',
error: 'Error: {error}',
noConnection: 'No connection',
autoRefresh: 'Auto Refresh',
searchInfo: 'Search INFO...',
noResults: 'No results',
keyStats: 'Key Statistics',
dbColumn: 'Database',
keysColumn: 'Keys',
expiresColumn: 'Expires',
avgTtlColumn: 'Avg TTL',
sectionServer: 'Server',
sectionClients: 'Clients',
sectionMemory: 'Memory',
@@ -269,6 +287,8 @@ export default {
},
db: {
label: 'DB',
keyCount: '{n} keys',
noKeys: 'empty',
},
settings: {
title: 'Settings',
+20
View File
@@ -179,6 +179,14 @@ export default {
ack: 'ACK',
ackConfirm: '确认 ACK 选中的条目?',
noGroups: '暂无消费者组',
valueTooLarge: '值过大无法显示 (>20MB)',
download: '下载',
setFieldTtl: '设置字段 TTL',
fieldTtl: '字段 TTL',
sortAsc: '升序',
sortDesc: '降序',
filteredCount: '已过滤 {n} 项',
fieldCount: '{n} 个字段',
},
cli: {
title: 'CLI',
@@ -205,6 +213,8 @@ export default {
time: '时间',
duration: '耗时',
command: '命令',
threshold: '阈值',
maxLen: '最大长度',
},
memory: {
title: '内存分析',
@@ -255,6 +265,14 @@ export default {
connecting: '连接中...',
error: '错误: {error}',
noConnection: '无连接',
autoRefresh: '自动刷新',
searchInfo: '搜索 INFO...',
noResults: '无匹配结果',
keyStats: '键空间统计',
dbColumn: '数据库',
keysColumn: '键数',
expiresColumn: '过期数',
avgTtlColumn: '平均 TTL',
sectionServer: '服务器',
sectionClients: '客户端',
sectionMemory: '内存',
@@ -269,6 +287,8 @@ export default {
},
db: {
label: '数据库',
keyCount: '{n} 个键',
noKeys: '空',
},
settings: {
title: '设置',
+33 -2
View File
@@ -42,8 +42,17 @@ export const useConnectionStore = defineStore('connection', () => {
const serverInfoMap = ref<Record<string, string>>({})
const healthMap = ref<Record<string, boolean>>({})
const currentDb = ref<number>(0)
const lastDbMap = ref<Record<string, number>>({})
const healthTimers = new Map<string, ReturnType<typeof setInterval>>()
// Load lastDbMap from localStorage
try {
const saved = localStorage.getItem('lastDbMap')
if (saved) {
lastDbMap.value = JSON.parse(saved)
}
} catch { /* ignore */ }
const activeConnection = computed(() =>
connections.value.find((c: ConnectionConfig) => c.id === activeId.value) ?? null
)
@@ -123,6 +132,14 @@ export const useConnectionStore = defineStore('connection', () => {
connectedIds.value.push(conn.id)
activeId.value = conn.id
serverInfoMap.value[conn.id] = await window.electronAPI.redis.getInfo(conn.id)
// Restore last selected DB for this connection
const lastDb = lastDbMap.value[conn.id] ?? 0
if (lastDb !== 0) {
try {
await window.electronAPI.redis.selectDb(conn.id, lastDb)
} catch { /* ignore */ }
}
currentDb.value = lastDb
startHealthCheck(conn.id)
} else {
error.value = result.error || 'Connection failed'
@@ -134,9 +151,16 @@ export const useConnectionStore = defineStore('connection', () => {
}
}
function switchTo(id: string) {
async function switchTo(id: string) {
if (connectedIds.value.includes(id)) {
activeId.value = id
const lastDb = lastDbMap.value[id] ?? 0
if (lastDb !== currentDb.value) {
try {
await window.electronAPI.redis.selectDb(id, lastDb)
} catch { /* ignore */ }
}
currentDb.value = lastDb
}
}
@@ -253,6 +277,12 @@ export const useConnectionStore = defineStore('connection', () => {
return data
}
function saveLastDb(connId: string, db: number) {
lastDbMap.value[connId] = db
currentDb.value = db
localStorage.setItem('lastDbMap', JSON.stringify(lastDbMap.value))
}
async function importConnections(jsonStr: string): Promise<number> {
try {
const imported = JSON.parse(jsonStr) as ConnectionConfig[]
@@ -274,9 +304,10 @@ export const useConnectionStore = defineStore('connection', () => {
}
return {
connections, activeId, connectedIds, connecting, error, serverInfo, healthOk, currentDb,
connections, activeId, connectedIds, connecting, error, serverInfo, healthOk, currentDb, lastDbMap,
activeConnection, isConnected,
loadConnections, decryptPassword, getInfo, saveConnection, deleteConnection, reorderConnections,
connect, disconnect, disconnectById, switchTo, isConnectedById, testConnection, exportConnections, importConnections,
saveLastDb,
}
})