121 lines
5.0 KiB
Vue
121 lines
5.0 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
|
import { useI18n } from "vue-i18n";
|
|
import { Code2, Copy } from "@lucide/vue";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useToast } from "@/composables/useToast";
|
|
import { useTheme } from "@/composables/useTheme";
|
|
import { copyToClipboard } from "@/lib/common/clipboard";
|
|
import { parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
|
|
import { createShikiJsonHighlighter, type JsonHighlighter } from "@/lib/common/shikiJsonHighlighter";
|
|
import JsonTree from "./JsonTree.vue";
|
|
|
|
const props = defineProps<{
|
|
status: number;
|
|
body: string;
|
|
}>();
|
|
|
|
const { t } = useI18n();
|
|
const { toast } = useToast();
|
|
const { isDark } = useTheme();
|
|
const responseView = ref<"raw" | "json">("json");
|
|
const jsonTreeRef = ref<{ refresh: () => void }>();
|
|
const jsonHighlighter = ref<JsonHighlighter>();
|
|
|
|
const parsedBody = computed(() => {
|
|
try {
|
|
return { valid: true, value: parseJsonPreservingLargeNumbers(props.body) };
|
|
} catch {
|
|
return { valid: false, value: null };
|
|
}
|
|
});
|
|
|
|
const statusClass = computed(() => {
|
|
if (props.status >= 500) return "border-destructive/40 bg-destructive/10 text-destructive";
|
|
if (props.status >= 400) return "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
|
if (props.status >= 300) return "border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300";
|
|
return "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
|
});
|
|
|
|
const statusLabel = computed(() => `HTTP ${props.status}`);
|
|
const jsonAppearance = computed(() => (isDark.value ? "dark" : "light"));
|
|
|
|
watch(
|
|
() => props.body,
|
|
() => {
|
|
responseView.value = parsedBody.value.valid ? "json" : "raw";
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(responseView, (view) => {
|
|
if (view === "json") void nextTick(() => jsonTreeRef.value?.refresh());
|
|
});
|
|
|
|
async function copyResponse() {
|
|
try {
|
|
await copyToClipboard(props.body);
|
|
toast(t("grid.copied"), 2000);
|
|
} catch (error: any) {
|
|
toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000);
|
|
}
|
|
}
|
|
|
|
function highlightJson(value: string): string {
|
|
return jsonHighlighter.value?.(value, jsonAppearance.value) ?? escapeHtml(value);
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
onMounted(() => {
|
|
void createShikiJsonHighlighter({ appearance: () => jsonAppearance.value })
|
|
.then((highlight) => {
|
|
jsonHighlighter.value = highlight;
|
|
})
|
|
.catch(() => {
|
|
jsonHighlighter.value = undefined;
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<section data-elasticsearch-json-response-root class="flex h-full min-h-0 flex-col bg-background" :aria-label="t('redis.jsonView')">
|
|
<header class="flex min-h-11 shrink-0 items-center gap-2 border-b bg-muted/25 px-3 py-1.5 text-xs">
|
|
<div class="flex min-w-0 flex-1 items-center gap-2">
|
|
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm" aria-hidden="true">
|
|
<Code2 class="h-3.5 w-3.5" />
|
|
</span>
|
|
<div class="inline-flex h-7 items-center rounded-md border bg-muted/45 p-0.5">
|
|
<button type="button" class="h-6 rounded-[4px] px-2 text-xs transition-colors" :class="responseView === 'raw' ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" :aria-pressed="responseView === 'raw'" @click="responseView = 'raw'">
|
|
{{ t("redis.rawContent") }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="h-6 rounded-[4px] px-2 text-xs transition-colors"
|
|
:class="responseView === 'json' ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
|
:aria-pressed="responseView === 'json'"
|
|
:disabled="!parsedBody.valid"
|
|
@click="responseView = 'json'"
|
|
>
|
|
{{ t("redis.jsonView") }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<span class="shrink-0 rounded-full border px-2 py-0.5 font-mono text-[11px] font-medium tabular-nums" :class="statusClass" role="status" :aria-label="statusLabel">
|
|
{{ statusLabel }}
|
|
</span>
|
|
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('grid.copyJson')" :aria-label="t('grid.copyJson')" @click="copyResponse">
|
|
<Copy class="h-3.5 w-3.5" />
|
|
</Button>
|
|
</header>
|
|
<div class="min-h-0 flex-1 overflow-hidden bg-background p-4">
|
|
<pre v-show="responseView === 'raw' || !parsedBody.valid" class="m-0 h-full overflow-auto bg-transparent p-0 font-mono text-sm leading-6 whitespace-pre-wrap break-words">{{ body }}</pre>
|
|
<div v-if="parsedBody.valid" v-show="responseView === 'json'" class="h-full min-h-0">
|
|
<JsonTree ref="jsonTreeRef" :value="parsedBody.value" :highlight-json="highlightJson" :virtualized="true" class="dbx-editor-font-family text-sm leading-6" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|