feat: Redis browser overhaul — Navicat-style table, editing, and pagination

- Unified key table with columns: key, type, value preview, size, TTL
- Top-bottom split layout: key table above, value detail below
- Virtual scrolling via RecycleScroller for large key sets
- ZSet editing: add/remove members with scores
- TTL inline editing: click badge to modify expiration
- Batch key deletion with checkbox selection
- Paginated loading for Hash/Set/ZSet/List values (HSCAN/SSCAN/ZSCAN)
- Debounced auto-search (400ms) for key filtering
- Value previews fetched during key scan for instant display
This commit is contained in:
t8y2
2026-05-09 11:45:50 +08:00
parent d6e273e136
commit 1a1f128f2b
11 changed files with 960 additions and 212 deletions
+201 -100
View File
@@ -1,12 +1,24 @@
<script setup lang="ts">
import { computed, ref, onMounted } from "vue";
import { computed, ref, onMounted, onUnmounted } from "vue";
import { useI18n } from "vue-i18n";
import { Search, RefreshCw, Key, Loader2, ChevronRight, ChevronDown, FolderClosed, FolderOpen } from "lucide-vue-next";
import {
Search,
RefreshCw,
Loader2,
ChevronRight,
ChevronDown,
FolderClosed,
FolderOpen,
Trash2,
} from "lucide-vue-next";
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import RedisValueViewer from "./RedisValueViewer.vue";
import * as api from "@/lib/api";
import type { RedisKeyInfo } from "@/lib/api";
@@ -29,16 +41,27 @@ const treeKeys = ref<RedisKeyTreeNode[]>([]);
const loading = ref(false);
const searchPattern = ref("*");
const selectedKeyRaw = ref<string | null>(null);
const cursor = ref(0);
const hasMore = ref(false);
const expandedGroupIds = ref<Set<string>>(new Set());
const checkedKeys = ref<Set<string>>(new Set());
const showBatchDeleteConfirm = ref(false);
const PAGE_SIZE = 200;
const effectivePattern = computed(() => searchPattern.value.trim() || "*");
const isSearchMode = computed(() => effectivePattern.value !== "*");
const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === selectedKeyRaw.value) ?? null);
const visibleRows = computed(() => flattenVisibleRedisKeyTree(treeKeys.value, expandedGroupIds.value));
const visibleRows = computed(() =>
flattenVisibleRedisKeyTree(treeKeys.value, expandedGroupIds.value).map((row) => ({
...row,
id: row.node.id,
})),
);
function countLeaves(node: RedisKeyTreeNode): number {
if (node.kind === "leaf") return 1;
return node.children.reduce((sum, child) => sum + countLeaves(child), 0);
}
function rebuildTree(expandAll = false) {
const nextTree = buildRedisKeyTree(flatKeys.value, props.db);
@@ -62,35 +85,19 @@ function rebuildTree(expandAll = false) {
async function loadKeys() {
loading.value = true;
flatKeys.value = [];
selectedKeyRaw.value = null;
checkedKeys.value = new Set();
try {
const result = await api.redisScanKeys(props.connectionId, props.db, 0, effectivePattern.value, PAGE_SIZE);
flatKeys.value = result.keys;
cursor.value = result.cursor;
hasMore.value = result.cursor !== 0;
selectedKeyRaw.value = null;
rebuildTree(isSearchMode.value);
} finally {
loading.value = false;
}
}
async function loadMoreKeys() {
if (loading.value || !hasMore.value) return;
loading.value = true;
try {
const result = await api.redisScanKeys(
props.connectionId,
props.db,
cursor.value,
effectivePattern.value,
PAGE_SIZE,
);
const existingKeys = new Set(flatKeys.value.map((key) => key.key_raw));
flatKeys.value = [...flatKeys.value, ...result.keys.filter((key) => !existingKeys.has(key.key_raw))];
cursor.value = result.cursor;
hasMore.value = result.cursor !== 0;
rebuildTree(isSearchMode.value);
let cur = 0;
do {
const result = await api.redisScanKeys(props.connectionId, props.db, cur, effectivePattern.value, PAGE_SIZE);
const existingKeys = new Set(flatKeys.value.map((key) => key.key_raw));
flatKeys.value = [...flatKeys.value, ...result.keys.filter((key) => !existingKeys.has(key.key_raw))];
cur = result.cursor;
hasMore.value = cur !== 0;
rebuildTree(isSearchMode.value);
} while (cur !== 0);
} finally {
loading.value = false;
}
@@ -119,6 +126,30 @@ function onKeyDeleted() {
rebuildTree(false);
}
function toggleCheck(keyRaw: string, event: Event) {
event.stopPropagation();
const next = new Set(checkedKeys.value);
if (next.has(keyRaw)) next.delete(keyRaw);
else next.add(keyRaw);
checkedKeys.value = next;
}
function requestBatchDelete() {
if (checkedKeys.value.size === 0) return;
showBatchDeleteConfirm.value = true;
}
async function applyBatchDelete() {
const keys = [...checkedKeys.value];
await api.redisDeleteKeys(props.connectionId, props.db, keys);
flatKeys.value = flatKeys.value.filter((k) => !checkedKeys.value.has(k.key_raw));
if (selectedKeyRaw.value && checkedKeys.value.has(selectedKeyRaw.value)) {
selectedKeyRaw.value = null;
}
checkedKeys.value = new Set();
rebuildTree(false);
}
function typeColor(type: string): string {
switch (type) {
case "string":
@@ -138,95 +169,160 @@ function typeColor(type: string): string {
}
}
function formatSize(size: number, type: string): string {
if (type === "string") {
if (size >= 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${size} B`;
}
return String(size);
}
let searchTimer: ReturnType<typeof setTimeout> | null = null;
function onSearchInput() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(loadKeys, 400);
}
onUnmounted(() => {
if (searchTimer) clearTimeout(searchTimer);
});
onMounted(loadKeys);
</script>
<template>
<Splitpanes class="h-full">
<!-- Key list (left) -->
<Pane :size="30" :min-size="15" :max-size="50">
<Splitpanes class="h-full" horizontal>
<!-- Key table (top) -->
<Pane :size="selectedKey ? 50 : 100" :min-size="25">
<div class="h-full flex flex-col overflow-hidden">
<!-- Search bar -->
<!-- Toolbar -->
<div class="h-9 flex items-center gap-1 px-2 border-b shrink-0">
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<Input
v-model="searchPattern"
class="h-6 text-xs border-0 shadow-none focus-visible:ring-0"
:placeholder="t('redis.pattern')"
@input="onSearchInput"
@keydown.enter="loadKeys"
/>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" @click="loadKeys">
<Loader2 v-if="loading" class="h-3 w-3 animate-spin" />
<RefreshCw v-else class="h-3 w-3" />
</Button>
</div>
<!-- Key count -->
<div class="h-9 flex items-center px-3 text-xs text-muted-foreground border-b shrink-0">
{{ loading && flatKeys.length === 0 ? t("redis.loadingKeys") : t("redis.keys", { count: flatKeys.length }) }}
</div>
<!-- Key tree -->
<div class="flex-1 overflow-y-auto">
<div
v-for="row in visibleRows"
:key="row.node.id"
class="flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer hover:bg-accent/50 border-b border-border/50"
:class="{ 'bg-accent': row.node.kind === 'leaf' && selectedKeyRaw === row.node.keyRaw }"
:style="{ paddingLeft: `${12 + row.depth * 18}px` }"
:title="row.node.kind === 'leaf' ? row.node.fullKeyDisplay : row.node.pathSegments.join(':')"
@click="onRowClick(row.node)"
<span class="text-xs text-muted-foreground shrink-0 ml-1">{{
loading && flatKeys.length === 0 ? t("redis.loadingKeys") : t("redis.keys", { count: flatKeys.length })
}}</span>
<Button
v-if="checkedKeys.size > 0"
variant="ghost"
size="sm"
class="h-6 text-xs text-destructive shrink-0 ml-1"
@click="requestBatchDelete"
>
<template v-if="row.node.kind === 'group'">
<component
:is="expandedGroupIds.has(row.node.id) ? ChevronDown : ChevronRight"
class="w-3 h-3 shrink-0 text-muted-foreground"
/>
<component
:is="expandedGroupIds.has(row.node.id) ? FolderOpen : FolderClosed"
class="w-3 h-3 shrink-0 text-amber-500"
/>
<span class="truncate flex-1 font-mono">{{ row.node.label }}</span>
</template>
<template v-else>
<span class="w-3 h-3 shrink-0" />
<Key class="w-3 h-3 shrink-0" :class="typeColor(row.node.keyType)" />
<span class="truncate font-mono">{{ row.node.label || row.node.fullKeyDisplay }}</span>
<span
v-if="row.node.label !== row.node.fullKeyDisplay"
class="truncate flex-1 text-[10px] text-muted-foreground font-mono"
<Trash2 class="w-3 h-3 mr-1" />{{ checkedKeys.size }}
</Button>
</div>
<!-- Table header -->
<div
class="grid grid-cols-[minmax(12rem,0.35fr)_70px_1fr_60px_60px] border-b bg-muted/50 shrink-0 text-xs font-medium text-muted-foreground"
>
<div class="px-3 py-1 border-r">键</div>
<div class="px-2 py-1 border-r">类型</div>
<div class="px-3 py-1 border-r">值</div>
<div class="px-2 py-1 border-r text-right">大小</div>
<div class="px-2 py-1 text-right">TTL</div>
</div>
<!-- Table body -->
<div
v-if="flatKeys.length === 0 && !loading"
class="flex-1 flex items-center justify-center text-muted-foreground text-xs"
>
{{ t("redis.noKeys") }}
</div>
<div
v-else-if="loading && flatKeys.length === 0"
class="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-xs"
>
<Loader2 class="w-3.5 h-3.5 animate-spin" />
<span>{{ t("redis.loadingKeys") }}</span>
</div>
<RecycleScroller v-else class="flex-1" :items="visibleRows" :item-size="28" key-field="id">
<template #default="{ item: row }">
<div
class="grid grid-cols-[minmax(12rem,0.35fr)_80px_1fr_60px_60px] border-b text-xs cursor-pointer hover:bg-accent/50 group"
:class="{ 'bg-accent': row.node.kind === 'leaf' && selectedKeyRaw === row.node.keyRaw }"
:style="{ height: '28px' }"
@click="onRowClick(row.node)"
>
<!-- Key column -->
<div
class="px-3 flex items-center gap-1 border-r overflow-hidden"
:style="{ paddingLeft: `${12 + row.depth * 16}px` }"
>
{{ row.node.fullKeyDisplay }}
</span>
<span v-else class="flex-1" />
<Badge variant="outline" class="text-[10px] px-1 py-0 shrink-0">{{ row.node.keyType }}</Badge>
</template>
</div>
<div v-if="flatKeys.length === 0 && !loading" class="px-3 py-8 text-center text-muted-foreground text-xs">
{{ t("redis.noKeys") }}
</div>
<div
v-if="loading && flatKeys.length === 0"
class="px-3 py-8 flex items-center justify-center gap-2 text-muted-foreground text-xs"
>
<Loader2 class="w-3.5 h-3.5 animate-spin" />
<span>{{ t("redis.loadingKeys") }}</span>
</div>
<div v-if="hasMore || (loading && flatKeys.length > 0)" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loading" @click="loadMoreKeys">
<Loader2 v-if="loading" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
</div>
</div>
<template v-if="row.node.kind === 'group'">
<component
:is="expandedGroupIds.has(row.node.id) ? ChevronDown : ChevronRight"
class="w-3 h-3 shrink-0 text-muted-foreground"
/>
<component
:is="expandedGroupIds.has(row.node.id) ? FolderOpen : FolderClosed"
class="w-3 h-3 shrink-0 text-amber-500"
/>
<span class="truncate font-mono">{{ row.node.label }}</span>
<span class="text-muted-foreground ml-1">({{ countLeaves(row.node) }})</span>
</template>
<template v-else>
<input
type="checkbox"
class="w-3 h-3 shrink-0 accent-primary cursor-pointer opacity-0 group-hover:opacity-100"
:class="{ 'opacity-100': checkedKeys.has(row.node.keyRaw) }"
:checked="checkedKeys.has(row.node.keyRaw)"
@click="toggleCheck(row.node.keyRaw, $event)"
/>
<span class="truncate font-mono">{{ row.node.label }}</span>
</template>
</div>
<!-- Type column -->
<div class="border-r flex items-center justify-center">
<Badge
v-if="row.node.kind === 'leaf'"
variant="outline"
class="text-xs px-1.5 py-0"
:class="typeColor(row.node.keyType)"
>{{ row.node.keyType }}</Badge
>
</div>
<!-- Value preview column -->
<div class="px-3 flex items-center border-r truncate font-mono text-muted-foreground">
<span v-if="row.node.kind === 'leaf'" class="truncate">{{ row.node.valuePreview }}</span>
</div>
<!-- Size column -->
<div class="px-2 flex items-center justify-end border-r text-muted-foreground">
<template v-if="row.node.kind === 'leaf'">{{ formatSize(row.node.size, row.node.keyType) }}</template>
</div>
<!-- TTL column -->
<div class="px-2 flex items-center justify-end text-muted-foreground">
<template v-if="row.node.kind === 'leaf'">{{
row.node.ttl === -1 ? "∞" : `${row.node.ttl}s`
}}</template>
</div>
</div>
</template>
</RecycleScroller>
</div>
</Pane>
<!-- Value viewer (right) -->
<Pane :size="70">
<!-- Value viewer (bottom) -->
<Pane v-if="selectedKey" :size="50" :min-size="20">
<div class="h-full min-w-0">
<RedisValueViewer
v-if="selectedKey"
:key="selectedKey.key_raw"
:connection-id="connectionId"
:db="db"
@@ -234,10 +330,15 @@ onMounted(loadKeys);
:key-raw="selectedKey.key_raw"
@deleted="onKeyDeleted"
/>
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
{{ t("redis.selectKey") }}
</div>
</div>
</Pane>
</Splitpanes>
<DangerConfirmDialog
v-model:open="showBatchDeleteConfirm"
:message="t('dangerDialog.deleteMessage')"
:details="`${checkedKeys.size} keys`"
:confirm-label="t('dangerDialog.deleteConfirm')"
@confirm="applyBatchDelete"
/>
</template>
+217 -54
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { Copy, Trash2, Save, RefreshCw, Plus } from "lucide-vue-next";
import { Copy, Trash2, Save, RefreshCw, Plus, Loader2 } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -22,17 +22,24 @@ const emit = defineEmits<{ deleted: [] }>();
const data = ref<RedisValue | null>(null);
const loading = ref(false);
const loadingMore = ref(false);
const editValue = ref("");
const isEditing = ref(false);
const newField = ref("");
const newValue = ref("");
const newScore = ref("");
const showDeleteConfirm = ref(false);
const editingTtl = ref(false);
const ttlInput = ref("");
const collectionItems = ref<any[]>([]);
const scanCursor = ref<number | undefined>(undefined);
type PendingDelete =
| { kind: "key" }
| { kind: "hash"; field: string }
| { kind: "list"; index: number }
| { kind: "set"; member: string };
| { kind: "set"; member: string }
| { kind: "zset"; member: string };
const pendingDelete = ref<PendingDelete | null>(null);
@@ -44,23 +51,49 @@ const deleteDetails = computed(() => {
return t("dangerDialog.redisHashFieldDetails", { key: props.keyDisplay, field: pending.field });
if (pending.kind === "list")
return t("dangerDialog.redisListItemDetails", { key: props.keyDisplay, index: pending.index });
if (pending.kind === "zset")
return t("dangerDialog.redisSetMemberDetails", { key: props.keyDisplay, member: pending.member });
return t("dangerDialog.redisSetMemberDetails", { key: props.keyDisplay, member: pending.member });
});
const isBinaryStringValue = computed(() => data.value?.key_type === "string" && data.value?.value_is_binary);
const hasMore = computed(() => scanCursor.value != null && scanCursor.value > 0);
async function load() {
loading.value = true;
try {
data.value = await api.redisGetValue(props.connectionId, props.db, props.keyRaw);
scanCursor.value = data.value.scan_cursor ?? undefined;
if (data.value.key_type === "string") {
editValue.value = String(data.value.value);
} else if (["list", "set", "zset", "hash"].includes(data.value.key_type)) {
collectionItems.value = Array.isArray(data.value.value) ? [...data.value.value] : [];
}
} finally {
loading.value = false;
}
}
async function loadMore() {
if (!data.value || !hasMore.value || loadingMore.value) return;
loadingMore.value = true;
try {
const result = await api.redisLoadMore(
props.connectionId,
props.db,
props.keyRaw,
data.value.key_type,
scanCursor.value!,
200,
);
const newItems = Array.isArray(result.value) ? result.value : [];
collectionItems.value = [...collectionItems.value, ...newItems];
scanCursor.value = result.scan_cursor ?? undefined;
} finally {
loadingMore.value = false;
}
}
async function saveString() {
if (isBinaryStringValue.value) return;
await api.redisSetString(props.connectionId, props.db, props.keyRaw, editValue.value);
@@ -90,6 +123,26 @@ function copyValue() {
navigator.clipboard.writeText(text);
}
// TTL
function startEditTtl() {
if (!data.value) return;
ttlInput.value = data.value.ttl > 0 ? String(data.value.ttl) : "";
editingTtl.value = true;
}
async function saveTtl() {
const val = ttlInput.value.trim();
const ttl = val === "" || val === "-1" ? -1 : parseInt(val, 10);
if (isNaN(ttl)) return;
await api.redisSetTtl(props.connectionId, props.db, props.keyRaw, ttl);
editingTtl.value = false;
await load();
}
function cancelEditTtl() {
editingTtl.value = false;
}
// Hash
async function hashSet() {
if (!newField.value) return;
@@ -139,13 +192,32 @@ function requestSetRemove(member: string) {
showDeleteConfirm.value = true;
}
// ZSet
async function zsetAdd() {
if (!newValue.value) return;
const score = parseFloat(newScore.value || "0");
await api.redisZadd(props.connectionId, props.db, props.keyRaw, newValue.value, score);
newValue.value = "";
newScore.value = "";
await load();
}
async function applyZsetRemove(member: string) {
await api.redisZrem(props.connectionId, props.db, props.keyRaw, member);
await load();
}
function requestZsetRemove(member: string) {
pendingDelete.value = { kind: "zset", member };
showDeleteConfirm.value = true;
}
async function confirmDelete() {
const pending = pendingDelete.value;
if (!pending) return;
if (pending.kind === "key") await applyDeleteKey();
else if (pending.kind === "hash") await applyHashDel(pending.field);
else if (pending.kind === "list") await applyListRemove(pending.index);
else await applySetRemove(pending.member);
else if (pending.kind === "set") await applySetRemove(pending.member);
else if (pending.kind === "zset") await applyZsetRemove(pending.member);
pendingDelete.value = null;
}
@@ -168,10 +240,33 @@ onMounted(load);
<div class="h-9 flex items-center gap-2 px-4 border-b bg-muted/30 shrink-0">
<span class="font-mono text-sm font-medium truncate">{{ data.key_display }}</span>
<Badge variant="secondary" class="text-xs">{{ data.key_type }}</Badge>
<Badge v-if="data.ttl > 0" variant="outline" class="text-xs">TTL: {{ data.ttl }}s</Badge>
<Badge v-else-if="data.ttl === -1" variant="outline" class="text-xs opacity-50">{{
t("redis.noExpiry")
}}</Badge>
<template v-if="!editingTtl">
<Badge
v-if="data.ttl > 0"
variant="outline"
class="text-xs cursor-pointer hover:bg-accent"
@click="startEditTtl"
>TTL: {{ data.ttl }}s</Badge
>
<Badge
v-else-if="data.ttl === -1"
variant="outline"
class="text-xs opacity-50 cursor-pointer hover:bg-accent hover:opacity-100"
@click="startEditTtl"
>{{ t("redis.noExpiry") }}</Badge
>
</template>
<div v-else class="flex items-center gap-1">
<Input
v-model="ttlInput"
class="h-6 w-20 text-xs"
placeholder="seconds (-1=no expiry)"
autofocus
@keydown.enter="saveTtl"
@keydown.escape="cancelEditTtl"
/>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="saveTtl"><Save class="h-3 w-3" /></Button>
</div>
<span class="flex-1" />
<Button variant="ghost" size="icon" class="h-7 w-7" @click="load"><RefreshCw class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="copyValue"><Copy class="h-3.5 w-3.5" /></Button>
@@ -209,7 +304,7 @@ onMounted(load);
<div v-else-if="data.key_type === 'list'" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground">{{
t("redis.items", { count: Array.isArray(data.value) ? data.value.length : 0 })
t("redis.items", { count: collectionItems.length }) + (data.total != null ? ` / ${data.total}` : "")
}}</span>
<span class="flex-1" />
<Input v-model="newValue" class="h-6 w-40 text-xs" placeholder="value" @keydown.enter="listPush" />
@@ -217,21 +312,34 @@ onMounted(load);
><Plus class="w-3 h-3 mr-1" />Push</Button
>
</div>
<div class="grid grid-cols-[60px_1fr_32px] border-b bg-muted/50 shrink-0">
<div class="px-3 py-1 text-xs font-medium text-muted-foreground border-r">#</div>
<div class="px-3 py-1 text-xs font-medium text-muted-foreground">Value</div>
<div />
</div>
<div class="flex-1 overflow-y-auto">
<div
v-for="(item, idx) in data.value"
v-for="(item, idx) in collectionItems"
:key="idx"
class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-2 group"
class="grid grid-cols-[60px_1fr_32px] border-b text-sm font-mono hover:bg-accent/50 group"
>
<span class="text-muted-foreground text-xs w-8 shrink-0">{{ idx }}</span>
<span class="truncate flex-1">{{ item }}</span>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
@click="requestListRemove(Number(idx))"
><Trash2 class="w-3 h-3"
/></Button>
<div class="px-3 py-1.5 text-xs text-muted-foreground border-r">{{ idx }}</div>
<div class="px-3 py-1.5 truncate">{{ item }}</div>
<div class="flex items-center justify-center">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
@click="requestListRemove(Number(idx))"
><Trash2 class="w-3 h-3"
/></Button>
</div>
</div>
<div v-if="hasMore" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
</div>
</div>
</div>
@@ -240,7 +348,7 @@ onMounted(load);
<div v-else-if="data.key_type === 'set'" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground">{{
t("redis.items", { count: Array.isArray(data.value) ? data.value.length : 0 })
t("redis.items", { count: collectionItems.length }) + (data.total != null ? ` / ${data.total}` : "")
}}</span>
<span class="flex-1" />
<Input v-model="newValue" class="h-6 w-40 text-xs" placeholder="member" @keydown.enter="setAdd" />
@@ -248,20 +356,32 @@ onMounted(load);
><Plus class="w-3 h-3 mr-1" />Add</Button
>
</div>
<div class="grid grid-cols-[1fr_32px] border-b bg-muted/50 shrink-0">
<div class="px-3 py-1 text-xs font-medium text-muted-foreground">Member</div>
<div />
</div>
<div class="flex-1 overflow-y-auto">
<div
v-for="(item, idx) in data.value"
v-for="(item, idx) in collectionItems"
:key="idx"
class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-2 group"
class="grid grid-cols-[1fr_32px] border-b text-sm font-mono hover:bg-accent/50 group"
>
<span class="truncate flex-1">{{ item }}</span>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
@click="requestSetRemove(String(item))"
><Trash2 class="w-3 h-3"
/></Button>
<div class="px-3 py-1.5 truncate">{{ item }}</div>
<div class="flex items-center justify-center">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
@click="requestSetRemove(String(item))"
><Trash2 class="w-3 h-3"
/></Button>
</div>
</div>
<div v-if="hasMore" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
</div>
</div>
</div>
@@ -270,7 +390,7 @@ onMounted(load);
<div v-else-if="data.key_type === 'hash'" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground">{{
t("redis.fields", { count: Object.keys(data.value || {}).length })
t("redis.fields", { count: collectionItems.length }) + (data.total != null ? ` / ${data.total}` : "")
}}</span>
<span class="flex-1" />
<Input v-model="newField" class="h-6 w-24 text-xs" placeholder="field" />
@@ -279,37 +399,80 @@ onMounted(load);
><Plus class="w-3 h-3 mr-1" />Set</Button
>
</div>
<div class="grid grid-cols-[minmax(8rem,0.3fr)_1fr_32px] border-b bg-muted/50 shrink-0">
<div class="px-3 py-1 text-xs font-medium text-muted-foreground border-r">Field</div>
<div class="px-3 py-1 text-xs font-medium text-muted-foreground">Value</div>
<div />
</div>
<div class="flex-1 overflow-y-auto">
<div
v-for="(val, field) in data.value"
:key="String(field)"
class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-3 group"
v-for="(item, idx) in collectionItems"
:key="idx"
class="grid grid-cols-[minmax(8rem,0.3fr)_1fr_32px] border-b text-sm font-mono hover:bg-accent/50 group"
>
<span class="text-blue-500 shrink-0 min-w-24">{{ field }}</span>
<span class="truncate text-muted-foreground flex-1">{{ val }}</span>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
@click="requestHashDel(String(field))"
><Trash2 class="w-3 h-3"
/></Button>
<div class="px-3 py-1.5 text-blue-500 truncate border-r">{{ item.field }}</div>
<div class="px-3 py-1.5 truncate text-muted-foreground">{{ item.value }}</div>
<div class="flex items-center justify-center">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
@click="requestHashDel(String(item.field))"
><Trash2 class="w-3 h-3"
/></Button>
</div>
</div>
<div v-if="hasMore" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
</div>
</div>
</div>
<!-- Sorted Set (readonly) -->
<div v-else-if="data.key_type === 'zset'" class="flex-1 overflow-auto">
<div class="px-4 py-1 text-xs text-muted-foreground border-b">
{{ t("redis.members", { count: Array.isArray(data.value) ? data.value.length : 0 }) }}
<!-- Sorted Set -->
<div v-else-if="data.key_type === 'zset'" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground">{{
t("redis.members", { count: collectionItems.length }) + (data.total != null ? ` / ${data.total}` : "")
}}</span>
<span class="flex-1" />
<Input v-model="newScore" class="h-6 w-20 text-xs" placeholder="score" />
<Input v-model="newValue" class="h-6 w-32 text-xs" placeholder="member" @keydown.enter="zsetAdd" />
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="zsetAdd"
><Plus class="w-3 h-3 mr-1" />Add</Button
>
</div>
<div
v-for="(item, idx) in data.value"
:key="idx"
class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-3"
>
<span class="text-muted-foreground text-xs w-16 shrink-0">{{ item.score }}</span>
<span class="truncate">{{ item.member }}</span>
<div class="grid grid-cols-[100px_1fr_32px] border-b bg-muted/50 shrink-0">
<div class="px-3 py-1 text-xs font-medium text-muted-foreground border-r">Score</div>
<div class="px-3 py-1 text-xs font-medium text-muted-foreground">Member</div>
<div />
</div>
<div class="flex-1 overflow-y-auto">
<div
v-for="(item, idx) in collectionItems"
:key="idx"
class="grid grid-cols-[100px_1fr_32px] border-b text-sm font-mono hover:bg-accent/50 group"
>
<div class="px-3 py-1.5 text-muted-foreground text-xs border-r">{{ item.score }}</div>
<div class="px-3 py-1.5 truncate">{{ item.member }}</div>
<div class="flex items-center justify-center">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
@click="requestZsetRemove(String(item.member))"
><Trash2 class="w-3 h-3"
/></Button>
</div>
</div>
<div v-if="hasMore" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
</div>
</div>
</div>
+1 -1
View File
@@ -538,7 +538,7 @@ export default {
pattern: "匹配模式 (如 user:*)",
keys: "{count} 个 key",
loadingKeys: "正在加载 key...",
loadMoreKeys: "继续加载 key",
loadMoreKeys: "加载更多",
items: "{count} 个元素",
fields: "{count} 个字段",
members: "{count} 个成员",
+5
View File
@@ -93,6 +93,11 @@ export const redisListPush = forward("redisListPush");
export const redisListRemove = forward("redisListRemove");
export const redisSetAdd = forward("redisSetAdd");
export const redisSetRemove = forward("redisSetRemove");
export const redisZadd = forward("redisZadd");
export const redisZrem = forward("redisZrem");
export const redisSetTtl = forward("redisSetTtl");
export const redisDeleteKeys = forward("redisDeleteKeys");
export const redisLoadMore = forward("redisLoadMore");
// MongoDB
export const mongoListDatabases = forward("mongoListDatabases");
+33
View File
@@ -498,6 +498,39 @@ export async function redisSetRemove(connectionId: string, db: number, keyRaw: s
return post("/api/redis/set-remove", { connectionId, db, keyRaw, member });
}
export async function redisZadd(
connectionId: string,
db: number,
keyRaw: string,
member: string,
score: number,
): Promise<void> {
return post("/api/redis/zadd", { connectionId, db, keyRaw, member, score });
}
export async function redisZrem(connectionId: string, db: number, keyRaw: string, member: string): Promise<void> {
return post("/api/redis/zrem", { connectionId, db, keyRaw, member });
}
export async function redisSetTtl(connectionId: string, db: number, keyRaw: string, ttl: number): Promise<void> {
return post("/api/redis/set-ttl", { connectionId, db, keyRaw, ttl });
}
export async function redisDeleteKeys(connectionId: string, db: number, keyRaws: string[]): Promise<number> {
return post("/api/redis/delete-keys", { connectionId, db, keyRaws });
}
export async function redisLoadMore(
connectionId: string,
db: number,
keyRaw: string,
keyType: string,
cursor: number,
count: number,
): Promise<RedisValue> {
return post("/api/redis/load-more", { connectionId, db, keyRaw, keyType, cursor, count });
}
// ---------------------------------------------------------------------------
// MongoDB
// ---------------------------------------------------------------------------
+6
View File
@@ -9,6 +9,8 @@ export interface RedisKeyTreeLeafNode {
db: number;
keyType: string;
ttl: number;
size: number;
valuePreview: string;
pathSegments: string[];
}
@@ -67,6 +69,8 @@ export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number): RedisKeyTre
db,
keyType: key.key_type,
ttl: key.ttl,
size: key.size,
valuePreview: key.value_preview,
pathSegments,
});
continue;
@@ -101,6 +105,8 @@ export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number): RedisKeyTre
db,
keyType: key.key_type,
ttl: key.ttl,
size: key.size,
valuePreview: key.value_preview,
pathSegments,
});
}
+37
View File
@@ -262,6 +262,8 @@ export interface RedisKeyInfo {
key_raw: string;
key_type: string;
ttl: number;
size: number;
value_preview: string;
}
export interface RedisValue {
@@ -271,6 +273,8 @@ export interface RedisValue {
ttl: number;
value_is_binary: boolean;
value: any;
total?: number;
scan_cursor?: number;
}
export interface RedisScanResult {
@@ -340,6 +344,39 @@ export async function redisSetRemove(connectionId: string, db: number, keyRaw: s
return invoke("redis_set_remove", { connectionId, db, keyRaw, member });
}
export async function redisZadd(
connectionId: string,
db: number,
keyRaw: string,
member: string,
score: number,
): Promise<void> {
return invoke("redis_zadd", { connectionId, db, keyRaw, member, score });
}
export async function redisZrem(connectionId: string, db: number, keyRaw: string, member: string): Promise<void> {
return invoke("redis_zrem", { connectionId, db, keyRaw, member });
}
export async function redisSetTtl(connectionId: string, db: number, keyRaw: string, ttl: number): Promise<void> {
return invoke("redis_set_ttl", { connectionId, db, keyRaw, ttl });
}
export async function redisDeleteKeys(connectionId: string, db: number, keyRaws: string[]): Promise<number> {
return invoke("redis_delete_keys", { connectionId, db, keyRaws });
}
export async function redisLoadMore(
connectionId: string,
db: number,
keyRaw: string,
keyType: string,
cursor: number,
count: number,
): Promise<RedisValue> {
return invoke("redis_load_more", { connectionId, db, keyRaw, keyType, cursor, count });
}
// --- MongoDB ---
export interface MongoDocumentResult {
documents: any[];