mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-30 00:19:35 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -538,7 +538,7 @@ export default {
|
||||
pattern: "匹配模式 (如 user:*)",
|
||||
keys: "{count} 个 key",
|
||||
loadingKeys: "正在加载 key...",
|
||||
loadMoreKeys: "继续加载 key",
|
||||
loadMoreKeys: "加载更多",
|
||||
items: "{count} 个元素",
|
||||
fields: "{count} 个字段",
|
||||
members: "{count} 个成员",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user