feat: add Redis member detail viewer

This commit is contained in:
t8y2 2026-05-13 11:12:32 +08:00
parent 4d4c8f4224
commit fea3552774
6 changed files with 199 additions and 14 deletions

View File

@ -1,13 +1,15 @@
<script setup lang="ts">
import { computed, ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { Copy, Trash2, Save, RefreshCw, Plus, Loader2 } from "lucide-vue-next";
import { Copy, Eye, 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";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import * as api from "@/lib/api";
import type { RedisValue } from "@/lib/api";
import { formatRedisMemberDetail } from "@/lib/redisValuePresentation";
const { t } = useI18n();
@ -29,10 +31,14 @@ const newField = ref("");
const newValue = ref("");
const newScore = ref("");
const showDeleteConfirm = ref(false);
const showMemberDetail = ref(false);
const editingTtl = ref(false);
const ttlInput = ref("");
const collectionItems = ref<any[]>([]);
const scanCursor = ref<number | undefined>(undefined);
const selectedMemberTitle = ref("");
const selectedMemberRaw = ref<unknown>("");
const selectedMemberDetail = computed(() => formatRedisMemberDetail(selectedMemberRaw.value));
type PendingDelete =
| { kind: "key" }
@ -123,6 +129,20 @@ function copyValue() {
navigator.clipboard.writeText(text);
}
function copyText(text: string) {
navigator.clipboard.writeText(text);
}
function copyMember(value: unknown) {
copyText(formatRedisMemberDetail(value).text);
}
function viewMember(title: string, value: unknown) {
selectedMemberTitle.value = title;
selectedMemberRaw.value = value;
showMemberDetail.value = true;
}
// TTL
function startEditTtl() {
if (!data.value) return;
@ -312,7 +332,7 @@ 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="grid grid-cols-[60px_1fr_84px] 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 />
@ -321,11 +341,27 @@ onMounted(load);
<div
v-for="(item, idx) in collectionItems"
:key="idx"
class="grid grid-cols-[60px_1fr_32px] border-b text-sm font-mono hover:bg-accent/50 group"
class="grid grid-cols-[60px_1fr_84px] border-b text-sm font-mono hover:bg-accent/50 group"
>
<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">
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click="viewMember(`#${idx}`, item)"
><Eye class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.copyMember')"
@click="copyMember(item)"
><Copy class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
@ -356,7 +392,7 @@ 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="grid grid-cols-[1fr_84px] border-b bg-muted/50 shrink-0">
<div class="px-3 py-1 text-xs font-medium text-muted-foreground">Member</div>
<div />
</div>
@ -364,10 +400,26 @@ onMounted(load);
<div
v-for="(item, idx) in collectionItems"
:key="idx"
class="grid grid-cols-[1fr_32px] border-b text-sm font-mono hover:bg-accent/50 group"
class="grid grid-cols-[1fr_84px] border-b text-sm font-mono hover:bg-accent/50 group"
>
<div class="px-3 py-1.5 truncate">{{ item }}</div>
<div class="flex items-center justify-center">
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click="viewMember(t('redis.member'), item)"
><Eye class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.copyMember')"
@click="copyMember(item)"
><Copy class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
@ -399,7 +451,7 @@ 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="grid grid-cols-[minmax(8rem,0.3fr)_1fr_84px] 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 />
@ -408,11 +460,27 @@ onMounted(load);
<div
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"
class="grid grid-cols-[minmax(8rem,0.3fr)_1fr_84px] border-b text-sm font-mono hover:bg-accent/50 group"
>
<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">
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click="viewMember(String(item.field), item.value)"
><Eye class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.copyMember')"
@click="copyMember(item.value)"
><Copy class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
@ -444,7 +512,7 @@ onMounted(load);
><Plus class="w-3 h-3 mr-1" />Add</Button
>
</div>
<div class="grid grid-cols-[100px_1fr_32px] border-b bg-muted/50 shrink-0">
<div class="grid grid-cols-[100px_1fr_84px] 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 />
@ -453,11 +521,27 @@ onMounted(load);
<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"
class="grid grid-cols-[100px_1fr_84px] 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">
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click="viewMember(String(item.score), item.member)"
><Eye class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.copyMember')"
@click="copyMember(item.member)"
><Copy class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
@ -490,10 +574,28 @@ onMounted(load);
<div
v-for="(val, field) in entry.fields"
:key="String(field)"
class="grid grid-cols-[minmax(6rem,0.35fr)_1fr] gap-3 py-0.5"
class="grid grid-cols-[minmax(6rem,0.35fr)_1fr_56px] gap-3 py-0.5 group"
>
<span class="truncate text-blue-500">{{ field }}</span>
<span class="truncate text-muted-foreground">{{ val }}</span>
<span class="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click="viewMember(String(field), val)"
><Eye class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.copyMember')"
@click="copyMember(val)"
><Copy class="w-3 h-3"
/></Button>
</span>
</div>
</div>
</div>
@ -511,5 +613,26 @@ onMounted(load);
:confirm-label="t('dangerDialog.deleteConfirm')"
@confirm="confirmDelete"
/>
<Dialog v-model:open="showMemberDetail">
<DialogContent class="sm:max-w-[760px] max-h-[82vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle class="flex items-center gap-2 pr-8">
<span class="truncate">{{ selectedMemberTitle || t("redis.memberDetail") }}</span>
<Badge variant="outline" class="shrink-0 text-xs">{{ selectedMemberDetail.format.toUpperCase() }}</Badge>
</DialogTitle>
</DialogHeader>
<pre
class="min-h-0 flex-1 overflow-auto rounded-md border bg-muted/20 p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words"
>{{ selectedMemberDetail.text }}</pre
>
<DialogFooter class="shrink-0">
<Button variant="outline" @click="copyText(selectedMemberDetail.text)">
<Copy class="h-4 w-4" />
{{ t("redis.copyMember") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>

View File

@ -715,6 +715,10 @@ export default {
executeCommand: "Execute command",
commandEmpty: "Enter a Redis command",
commandBlocked: "This Redis command is blocked for safety",
member: "Member",
memberDetail: "Member detail",
viewMember: "View full value",
copyMember: "Copy member",
},
mongo: {
documents: "{count} documents",

View File

@ -716,6 +716,10 @@ export default {
executeCommand: "Ejecutar comando",
commandEmpty: "Ingresa un comando Redis",
commandBlocked: "Este comando Redis está bloqueado por seguridad",
member: "Miembro",
memberDetail: "Detalle del miembro",
viewMember: "Ver valor completo",
copyMember: "Copiar miembro",
},
mongo: {
documents: "{count} documentos",

View File

@ -701,6 +701,10 @@ export default {
executeCommand: "执行命令",
commandEmpty: "请输入 Redis 命令",
commandBlocked: "出于安全考虑,此 Redis 命令已被阻止",
member: "成员",
memberDetail: "成员详情",
viewMember: "查看完整内容",
copyMember: "复制成员",
},
mongo: {
documents: "{count} 个文档",

View File

@ -0,0 +1,26 @@
export type RedisMemberDetailFormat = "json" | "text";
export interface RedisMemberDetail {
text: string;
format: RedisMemberDetailFormat;
}
export function formatRedisMemberDetail(value: unknown): RedisMemberDetail {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed) {
try {
return { text: JSON.stringify(JSON.parse(trimmed), null, 2), format: "json" };
} catch {
/* fall through to plain text */
}
}
return { text: value, format: "text" };
}
try {
return { text: JSON.stringify(value, null, 2), format: "json" };
} catch {
return { text: String(value), format: "text" };
}
}

View File

@ -0,0 +1,24 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { formatRedisMemberDetail } from "../src/lib/redisValuePresentation.ts";
test("formats JSON object strings for Redis member details", () => {
const detail = formatRedisMemberDetail('{"id":1,"name":"Ada","tags":["dbx","redis"]}');
assert.equal(detail.format, "json");
assert.equal(detail.text, '{\n "id": 1,\n "name": "Ada",\n "tags": [\n "dbx",\n "redis"\n ]\n}');
});
test("keeps plain Redis member strings unchanged", () => {
const detail = formatRedisMemberDetail("plain long member value");
assert.equal(detail.format, "text");
assert.equal(detail.text, "plain long member value");
});
test("formats non-string Redis member values as JSON", () => {
const detail = formatRedisMemberDetail({ field: "name", value: '{"nested":true}' });
assert.equal(detail.format, "json");
assert.equal(detail.text, '{\n "field": "name",\n "value": "{\\"nested\\":true}"\n}');
});