mirror of
https://github.com/t8y2/dbx.git
synced 2026-11-02 00:29:30 +00:00
feat: add copy toast feedback for Redis, fix sheet overlay close lag
- Add "已复制" toast for all Redis copy operations (value, member, detail) - Remove sheet overlay exit animation to fix slow overlay-click close
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from "vue";
|
||||
import { computed, ref, onBeforeUnmount, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Copy, Eye, Trash2, Save, RefreshCw, Plus, Loader2 } from "lucide-vue-next";
|
||||
import { Copy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil } 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 { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import type { RedisValue } from "@/lib/api";
|
||||
import { formatRedisMemberDetail } from "@/lib/redisValuePresentation";
|
||||
import {
|
||||
canEditRedisMemberDetail,
|
||||
clampRedisMemberDetailSheetWidth,
|
||||
formatRedisMemberDetail,
|
||||
getRedisMemberSelectionKey,
|
||||
highlightRedisJsonDetail,
|
||||
} from "@/lib/redisValuePresentation";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -38,7 +44,20 @@ const collectionItems = ref<any[]>([]);
|
||||
const scanCursor = ref<number | undefined>(undefined);
|
||||
const selectedMemberTitle = ref("");
|
||||
const selectedMemberRaw = ref<unknown>("");
|
||||
const selectedMemberKey = ref("");
|
||||
const selectedMemberContext = ref<RedisMemberContext | null>(null);
|
||||
const isEditingMember = ref(false);
|
||||
const savingMember = ref(false);
|
||||
const memberEditValue = ref("");
|
||||
const memberDetailSheetWidth = ref(420);
|
||||
const isResizingMemberSheet = ref(false);
|
||||
const selectedMemberDetail = computed(() => formatRedisMemberDetail(selectedMemberRaw.value));
|
||||
const selectedMemberJsonHtml = computed(() =>
|
||||
selectedMemberDetail.value.format === "json" ? highlightRedisJsonDetail(selectedMemberDetail.value.text) : "",
|
||||
);
|
||||
const selectedMemberCanEdit = computed(
|
||||
() => selectedMemberContext.value != null && canEditRedisMemberDetail(selectedMemberContext.value.kind),
|
||||
);
|
||||
|
||||
type PendingDelete =
|
||||
| { kind: "key" }
|
||||
@@ -49,6 +68,16 @@ type PendingDelete =
|
||||
|
||||
const pendingDelete = ref<PendingDelete | null>(null);
|
||||
|
||||
let memberSheetResizeStartX = 0;
|
||||
let memberSheetResizeStartWidth = 0;
|
||||
|
||||
type RedisMemberContext =
|
||||
| { kind: "list"; index: number }
|
||||
| { kind: "set"; member: string }
|
||||
| { kind: "hash"; field: string }
|
||||
| { kind: "zset"; member: string; score: number }
|
||||
| { kind: "stream"; field: string };
|
||||
|
||||
const deleteDetails = computed(() => {
|
||||
const pending = pendingDelete.value;
|
||||
if (!pending) return "";
|
||||
@@ -65,15 +94,22 @@ const deleteDetails = computed(() => {
|
||||
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() {
|
||||
async function load(options: { selectDefaultMember?: boolean } = {}) {
|
||||
const shouldSelectDefaultMember = options.selectDefaultMember ?? true;
|
||||
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);
|
||||
clearSelectedMember();
|
||||
} else if (["list", "set", "zset", "hash"].includes(data.value.key_type)) {
|
||||
collectionItems.value = Array.isArray(data.value.value) ? [...data.value.value] : [];
|
||||
if (shouldSelectDefaultMember) selectDefaultMember(data.value);
|
||||
} else if (data.value.key_type === "stream") {
|
||||
if (shouldSelectDefaultMember) selectDefaultMember(data.value);
|
||||
} else {
|
||||
clearSelectedMember();
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -127,22 +163,170 @@ function copyValue() {
|
||||
if (!data.value) return;
|
||||
const text = typeof data.value.value === "string" ? data.value.value : JSON.stringify(data.value.value, null, 2);
|
||||
navigator.clipboard.writeText(text);
|
||||
toast(t("redis.copied"), 2000);
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast(t("redis.copied"), 2000);
|
||||
}
|
||||
|
||||
function copyMember(value: unknown) {
|
||||
copyText(formatRedisMemberDetail(value).text);
|
||||
}
|
||||
|
||||
function viewMember(title: string, value: unknown) {
|
||||
function selectMember(title: string, value: unknown, context: RedisMemberContext) {
|
||||
selectedMemberTitle.value = title;
|
||||
selectedMemberRaw.value = value;
|
||||
selectedMemberKey.value = getRedisMemberSelectionKey(title, value);
|
||||
selectedMemberContext.value = context;
|
||||
isEditingMember.value = false;
|
||||
memberEditValue.value = formatRedisMemberDetail(value).text;
|
||||
}
|
||||
|
||||
function clearSelectedMember() {
|
||||
selectedMemberTitle.value = "";
|
||||
selectedMemberRaw.value = "";
|
||||
selectedMemberKey.value = "";
|
||||
selectedMemberContext.value = null;
|
||||
isEditingMember.value = false;
|
||||
memberEditValue.value = "";
|
||||
}
|
||||
|
||||
function isSelectedMember(title: string, value: unknown) {
|
||||
return selectedMemberKey.value === getRedisMemberSelectionKey(title, value);
|
||||
}
|
||||
|
||||
function viewMember(title: string, value: unknown, context: RedisMemberContext) {
|
||||
selectMember(title, value, context);
|
||||
showMemberDetail.value = true;
|
||||
}
|
||||
|
||||
function handleMemberDetailOpenChange(open: boolean) {
|
||||
showMemberDetail.value = open;
|
||||
if (!open) isEditingMember.value = false;
|
||||
}
|
||||
|
||||
function finishMemberDetailClose() {
|
||||
isEditingMember.value = false;
|
||||
}
|
||||
|
||||
function stopResizeMemberSheet() {
|
||||
isResizingMemberSheet.value = false;
|
||||
window.removeEventListener("pointermove", resizeMemberSheet);
|
||||
window.removeEventListener("pointerup", stopResizeMemberSheet);
|
||||
}
|
||||
|
||||
function resizeMemberSheet(event: PointerEvent) {
|
||||
if (!isResizingMemberSheet.value) return;
|
||||
const delta = memberSheetResizeStartX - event.clientX;
|
||||
memberDetailSheetWidth.value = clampRedisMemberDetailSheetWidth(
|
||||
memberSheetResizeStartWidth + delta,
|
||||
window.innerWidth,
|
||||
);
|
||||
}
|
||||
|
||||
function startResizeMemberSheet(event: PointerEvent) {
|
||||
isResizingMemberSheet.value = true;
|
||||
memberSheetResizeStartX = event.clientX;
|
||||
memberSheetResizeStartWidth = memberDetailSheetWidth.value;
|
||||
window.addEventListener("pointermove", resizeMemberSheet);
|
||||
window.addEventListener("pointerup", stopResizeMemberSheet);
|
||||
}
|
||||
|
||||
function startEditMember() {
|
||||
memberEditValue.value = selectedMemberDetail.value.text;
|
||||
isEditingMember.value = true;
|
||||
}
|
||||
|
||||
function cancelEditMember() {
|
||||
memberEditValue.value = selectedMemberDetail.value.text;
|
||||
isEditingMember.value = false;
|
||||
}
|
||||
|
||||
async function saveMemberEdit() {
|
||||
const context = selectedMemberContext.value;
|
||||
if (!context || !selectedMemberCanEdit.value) return;
|
||||
const title = selectedMemberTitle.value;
|
||||
let nextContext: RedisMemberContext = context;
|
||||
savingMember.value = true;
|
||||
try {
|
||||
if (context.kind === "list") {
|
||||
await api.redisListSet(props.connectionId, props.db, props.keyRaw, context.index, memberEditValue.value);
|
||||
} else if (context.kind === "hash") {
|
||||
await api.redisHashSet(props.connectionId, props.db, props.keyRaw, context.field, memberEditValue.value);
|
||||
} else if (context.kind === "set") {
|
||||
await api.redisSetRemove(props.connectionId, props.db, props.keyRaw, context.member);
|
||||
await api.redisSetAdd(props.connectionId, props.db, props.keyRaw, memberEditValue.value);
|
||||
nextContext = { kind: "set", member: memberEditValue.value };
|
||||
} else if (context.kind === "zset") {
|
||||
await api.redisZrem(props.connectionId, props.db, props.keyRaw, context.member);
|
||||
await api.redisZadd(props.connectionId, props.db, props.keyRaw, memberEditValue.value, context.score);
|
||||
nextContext = { kind: "zset", member: memberEditValue.value, score: context.score };
|
||||
}
|
||||
const editedValue = memberEditValue.value;
|
||||
isEditingMember.value = false;
|
||||
await load({ selectDefaultMember: false });
|
||||
selectMember(title, editedValue, nextContext);
|
||||
} finally {
|
||||
savingMember.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectDefaultMember(redisValue: RedisValue) {
|
||||
if (redisValue.key_type === "list" || redisValue.key_type === "set") {
|
||||
if (collectionItems.value.length === 0) {
|
||||
clearSelectedMember();
|
||||
return;
|
||||
}
|
||||
selectMember(
|
||||
redisValue.key_type === "list" ? "#0" : t("redis.member"),
|
||||
collectionItems.value[0],
|
||||
redisValue.key_type === "list"
|
||||
? { kind: "list", index: 0 }
|
||||
: { kind: "set", member: String(collectionItems.value[0]) },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (redisValue.key_type === "hash") {
|
||||
const first = collectionItems.value[0];
|
||||
if (!first) {
|
||||
clearSelectedMember();
|
||||
return;
|
||||
}
|
||||
selectMember(String(first.field), first.value, { kind: "hash", field: String(first.field) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (redisValue.key_type === "zset") {
|
||||
const first = collectionItems.value[0];
|
||||
if (!first) {
|
||||
clearSelectedMember();
|
||||
return;
|
||||
}
|
||||
selectMember(String(first.score), first.member, {
|
||||
kind: "zset",
|
||||
member: String(first.member),
|
||||
score: Number(first.score),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (redisValue.key_type === "stream" && Array.isArray(redisValue.value)) {
|
||||
const firstEntry = redisValue.value[0];
|
||||
const firstField = firstEntry?.fields ? Object.entries(firstEntry.fields)[0] : undefined;
|
||||
if (!firstField) {
|
||||
clearSelectedMember();
|
||||
return;
|
||||
}
|
||||
selectMember(String(firstField[0]), firstField[1], { kind: "stream", field: String(firstField[0]) });
|
||||
return;
|
||||
}
|
||||
|
||||
clearSelectedMember();
|
||||
}
|
||||
|
||||
// TTL
|
||||
function startEditTtl() {
|
||||
if (!data.value) return;
|
||||
@@ -247,6 +431,7 @@ function formatValue(val: any): string {
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
onBeforeUnmount(stopResizeMemberSheet);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -341,7 +526,9 @@ onMounted(load);
|
||||
<div
|
||||
v-for="(item, idx) in collectionItems"
|
||||
:key="idx"
|
||||
class="grid grid-cols-[60px_1fr_84px] 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 cursor-pointer"
|
||||
:class="{ 'bg-accent/60': isSelectedMember(`#${idx}`, item) }"
|
||||
@click="viewMember(`#${idx}`, item, { kind: 'list', index: Number(idx) })"
|
||||
>
|
||||
<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>
|
||||
@@ -351,7 +538,7 @@ onMounted(load);
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.viewMember')"
|
||||
@click="viewMember(`#${idx}`, item)"
|
||||
@click.stop="viewMember(`#${idx}`, item, { kind: 'list', index: Number(idx) })"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
@@ -359,14 +546,14 @@ onMounted(load);
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.copyMember')"
|
||||
@click="copyMember(item)"
|
||||
@click.stop="copyMember(item)"
|
||||
><Copy class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
@click="requestListRemove(Number(idx))"
|
||||
@click.stop="requestListRemove(Number(idx))"
|
||||
><Trash2 class="w-3 h-3"
|
||||
/></Button>
|
||||
</div>
|
||||
@@ -400,7 +587,9 @@ onMounted(load);
|
||||
<div
|
||||
v-for="(item, idx) in collectionItems"
|
||||
:key="idx"
|
||||
class="grid grid-cols-[1fr_84px] 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 cursor-pointer"
|
||||
:class="{ 'bg-accent/60': isSelectedMember(t('redis.member'), item) }"
|
||||
@click="viewMember(t('redis.member'), item, { kind: 'set', member: String(item) })"
|
||||
>
|
||||
<div class="px-3 py-1.5 truncate">{{ item }}</div>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
@@ -409,7 +598,7 @@ onMounted(load);
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.viewMember')"
|
||||
@click="viewMember(t('redis.member'), item)"
|
||||
@click.stop="viewMember(t('redis.member'), item, { kind: 'set', member: String(item) })"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
@@ -417,14 +606,14 @@ onMounted(load);
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.copyMember')"
|
||||
@click="copyMember(item)"
|
||||
@click.stop="copyMember(item)"
|
||||
><Copy class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
@click="requestSetRemove(String(item))"
|
||||
@click.stop="requestSetRemove(String(item))"
|
||||
><Trash2 class="w-3 h-3"
|
||||
/></Button>
|
||||
</div>
|
||||
@@ -460,7 +649,9 @@ onMounted(load);
|
||||
<div
|
||||
v-for="(item, idx) in collectionItems"
|
||||
:key="idx"
|
||||
class="grid grid-cols-[minmax(8rem,0.3fr)_1fr_84px] 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 cursor-pointer"
|
||||
:class="{ 'bg-accent/60': isSelectedMember(String(item.field), item.value) }"
|
||||
@click="viewMember(String(item.field), item.value, { kind: 'hash', field: String(item.field) })"
|
||||
>
|
||||
<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>
|
||||
@@ -470,7 +661,7 @@ onMounted(load);
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.viewMember')"
|
||||
@click="viewMember(String(item.field), item.value)"
|
||||
@click.stop="viewMember(String(item.field), item.value, { kind: 'hash', field: String(item.field) })"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
@@ -478,14 +669,14 @@ onMounted(load);
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.copyMember')"
|
||||
@click="copyMember(item.value)"
|
||||
@click.stop="copyMember(item.value)"
|
||||
><Copy class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
@click="requestHashDel(String(item.field))"
|
||||
@click.stop="requestHashDel(String(item.field))"
|
||||
><Trash2 class="w-3 h-3"
|
||||
/></Button>
|
||||
</div>
|
||||
@@ -521,7 +712,15 @@ onMounted(load);
|
||||
<div
|
||||
v-for="(item, idx) in collectionItems"
|
||||
:key="idx"
|
||||
class="grid grid-cols-[100px_1fr_84px] 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 cursor-pointer"
|
||||
:class="{ 'bg-accent/60': isSelectedMember(String(item.score), item.member) }"
|
||||
@click="
|
||||
viewMember(String(item.score), item.member, {
|
||||
kind: 'zset',
|
||||
member: String(item.member),
|
||||
score: Number(item.score),
|
||||
})
|
||||
"
|
||||
>
|
||||
<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>
|
||||
@@ -531,7 +730,13 @@ onMounted(load);
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.viewMember')"
|
||||
@click="viewMember(String(item.score), item.member)"
|
||||
@click.stop="
|
||||
viewMember(String(item.score), item.member, {
|
||||
kind: 'zset',
|
||||
member: String(item.member),
|
||||
score: Number(item.score),
|
||||
})
|
||||
"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
@@ -539,14 +744,14 @@ onMounted(load);
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.copyMember')"
|
||||
@click="copyMember(item.member)"
|
||||
@click.stop="copyMember(item.member)"
|
||||
><Copy class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
@click="requestZsetRemove(String(item.member))"
|
||||
@click.stop="requestZsetRemove(String(item.member))"
|
||||
><Trash2 class="w-3 h-3"
|
||||
/></Button>
|
||||
</div>
|
||||
@@ -574,7 +779,9 @@ onMounted(load);
|
||||
<div
|
||||
v-for="(val, field) in entry.fields"
|
||||
:key="String(field)"
|
||||
class="grid grid-cols-[minmax(6rem,0.35fr)_1fr_56px] gap-3 py-0.5 group"
|
||||
class="grid grid-cols-[minmax(6rem,0.35fr)_1fr_56px] gap-3 py-0.5 group cursor-pointer"
|
||||
:class="{ 'bg-accent/60': isSelectedMember(String(field), val) }"
|
||||
@click="viewMember(String(field), val, { kind: 'stream', field: String(field) })"
|
||||
>
|
||||
<span class="truncate text-blue-500">{{ field }}</span>
|
||||
<span class="truncate text-muted-foreground">{{ val }}</span>
|
||||
@@ -584,7 +791,7 @@ onMounted(load);
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.viewMember')"
|
||||
@click="viewMember(String(field), val)"
|
||||
@click.stop="viewMember(String(field), val, { kind: 'stream', field: String(field) })"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
@@ -592,7 +799,7 @@ onMounted(load);
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.copyMember')"
|
||||
@click="copyMember(val)"
|
||||
@click.stop="copyMember(val)"
|
||||
><Copy class="w-3 h-3"
|
||||
/></Button>
|
||||
</span>
|
||||
@@ -614,25 +821,114 @@ onMounted(load);
|
||||
@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">
|
||||
<Sheet :open="showMemberDetail" @update:open="handleMemberDetailOpenChange">
|
||||
<SheetContent
|
||||
side="right"
|
||||
class="gap-0 p-0 sm:max-w-[calc(100vw-2rem)]"
|
||||
:class="{ 'select-none': isResizingMemberSheet }"
|
||||
:style="{ width: `${memberDetailSheetWidth}px`, maxWidth: 'calc(100vw - 2rem)' }"
|
||||
@close-auto-focus="finishMemberDetailClose"
|
||||
@pointer-down-outside.prevent
|
||||
@interact-outside.prevent
|
||||
>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 z-10 w-2 -translate-x-1 cursor-col-resize border-l border-transparent hover:border-primary/60"
|
||||
@pointerdown.prevent="startResizeMemberSheet"
|
||||
/>
|
||||
<SheetHeader class="border-b px-5 py-4 pr-12">
|
||||
<SheetTitle class="flex items-center gap-2">
|
||||
<span class="truncate">{{ selectedMemberTitle || t("redis.memberDetail") }}</span>
|
||||
<Badge variant="outline" class="shrink-0 text-xs">{{ selectedMemberDetail.format.toUpperCase() }}</Badge>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<textarea
|
||||
v-if="isEditingMember"
|
||||
v-model="memberEditValue"
|
||||
class="min-h-0 flex-1 resize-none bg-background p-5 font-mono text-[13px] leading-6 outline-none"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<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"
|
||||
v-else-if="selectedMemberDetail.format === 'json'"
|
||||
class="json-viewer min-h-0 flex-1 overflow-auto bg-background p-5 font-mono text-[13px] leading-6"
|
||||
v-html="selectedMemberJsonHtml"
|
||||
/>
|
||||
<pre
|
||||
v-else
|
||||
class="min-h-0 flex-1 overflow-auto bg-background p-5 font-mono text-[13px] leading-6 whitespace-pre-wrap break-words"
|
||||
>{{ selectedMemberDetail.text }}</pre
|
||||
>
|
||||
<DialogFooter class="shrink-0">
|
||||
<SheetFooter class="shrink-0 border-t px-5 py-3">
|
||||
<template v-if="isEditingMember">
|
||||
<Button variant="ghost" :disabled="savingMember" @click="cancelEditMember">
|
||||
{{ t("grid.discard") }}
|
||||
</Button>
|
||||
<Button :disabled="savingMember" @click="saveMemberEdit">
|
||||
<Loader2 v-if="savingMember" class="h-4 w-4 animate-spin" />
|
||||
<Save v-else class="h-4 w-4" />
|
||||
{{ t("grid.save") }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button v-else-if="selectedMemberCanEdit" variant="outline" @click="startEditMember">
|
||||
<Pencil class="h-4 w-4" />
|
||||
{{ t("redis.editMember") }}
|
||||
</Button>
|
||||
<Button variant="outline" @click="copyText(selectedMemberDetail.text)">
|
||||
<Copy class="h-4 w-4" />
|
||||
{{ t("redis.copyMember") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.json-viewer {
|
||||
tab-size: 2;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:deep(.json-key) {
|
||||
color: #7c3aed;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.json-string) {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
:deep(.json-number) {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
:deep(.json-boolean) {
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.json-null) {
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.json-key) {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.json-string) {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.json-number) {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.json-boolean) {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.json-null) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DialogOverlay } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="sheet-overlay"
|
||||
:class="
|
||||
cn('bg-black/20 fixed inset-0 z-50 data-open:animate-in data-open:fade-in-0 data-open:duration-200', props.class)
|
||||
"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
Reference in New Issue
Block a user