mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
refactor(usage): 抽取 JsonContentPanel 通用 JSON 展示组件
请求详情抽屉与时间线统一复用工具栏(展开/收缩、复制)和 JSON 视图,时间线的额外信息也由 pre 改为可交互面板
This commit is contained in:
@@ -460,7 +460,12 @@
|
||||
<summary class="extra-toggle">
|
||||
额外信息
|
||||
</summary>
|
||||
<pre class="extra-json">{{ JSON.stringify(currentAttempt.extra_data, null, 2) }}</pre>
|
||||
<JsonContentPanel
|
||||
class="extra-json-panel"
|
||||
:data="currentAttempt.extra_data"
|
||||
:is-dark="isDark"
|
||||
empty-message="无额外信息"
|
||||
/>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
@@ -489,6 +494,7 @@ import { isAxiosError } from 'axios'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Skeleton from '@/components/ui/skeleton.vue'
|
||||
import JsonContentPanel from './JsonContentPanel.vue'
|
||||
import { ChevronLeft, ChevronRight, ExternalLink } from 'lucide-vue-next'
|
||||
import { requestTraceApi, type RequestTrace, type CandidateRecord } from '@/api/requestTrace'
|
||||
import { log } from '@/utils/logger'
|
||||
@@ -615,6 +621,7 @@ const getFinalStatusBadgeVariant = (status: string): BadgeVariant => {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const internalTrace = ref<RequestTrace | null>(null)
|
||||
const isDark = computed(() => document.documentElement.classList.contains('dark'))
|
||||
const trace = computed(() => props.traceData ?? internalTrace.value)
|
||||
const selectedGroupIndex = ref(0)
|
||||
const selectedAttemptIndex = ref(0)
|
||||
@@ -2381,16 +2388,8 @@ const getDisplayStatus = (attempt: CandidateRecord | null | undefined): string =
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.extra-json {
|
||||
.extra-json-panel {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
|
||||
201
frontend/src/features/usage/components/JsonContentPanel.vue
Normal file
201
frontend/src/features/usage/components/JsonContentPanel.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="json-content-panel">
|
||||
<div class="json-panel-toolbar">
|
||||
<span class="json-panel-title">{{ title }}</span>
|
||||
<div class="json-panel-actions">
|
||||
<slot name="toolbar-actions-before" />
|
||||
<button
|
||||
:title="panelExpandDepth === 0 ? '展开全部' : '收缩全部'"
|
||||
class="json-panel-action"
|
||||
:class="{ 'is-disabled': expandDisabled }"
|
||||
:disabled="expandDisabled"
|
||||
type="button"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<Maximize2
|
||||
v-if="panelExpandDepth === 0"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
<Minimize2
|
||||
v-else
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
:title="panelCopied ? '已复制' : '复制'"
|
||||
class="json-panel-action"
|
||||
:class="{ 'is-disabled': copyDisabled }"
|
||||
:disabled="copyDisabled"
|
||||
type="button"
|
||||
@click="copyJson"
|
||||
>
|
||||
<Check
|
||||
v-if="panelCopied"
|
||||
class="w-3.5 h-3.5 text-green-500"
|
||||
/>
|
||||
<Copy
|
||||
v-else
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="json-panel-content">
|
||||
<slot :expand-depth="panelExpandDepth">
|
||||
<JsonContent
|
||||
:data="data"
|
||||
view-mode="formatted"
|
||||
:expand-depth="panelExpandDepth"
|
||||
:is-dark="isDark"
|
||||
:empty-message="emptyMessage"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Check, Copy, Maximize2, Minimize2 } from 'lucide-vue-next'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import JsonContent from './RequestDetailDrawer/JsonContent.vue'
|
||||
|
||||
type JsonValue = Record<string, unknown> | unknown[] | string | number | boolean | null | undefined
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
data: JsonValue
|
||||
isDark: boolean
|
||||
emptyMessage?: string
|
||||
title?: string
|
||||
maxHeight?: string
|
||||
expandDepth?: number
|
||||
copied?: boolean
|
||||
customCopy?: boolean
|
||||
expandDisabled?: boolean
|
||||
copyDisabled?: boolean
|
||||
}>(), {
|
||||
emptyMessage: '无数据',
|
||||
title: 'JSON',
|
||||
maxHeight: '360px',
|
||||
expandDepth: undefined,
|
||||
copied: undefined,
|
||||
customCopy: false,
|
||||
expandDisabled: false,
|
||||
copyDisabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:expandDepth': [value: number]
|
||||
copy: []
|
||||
}>()
|
||||
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const internalExpandDepth = ref(0)
|
||||
const internalCopied = ref(false)
|
||||
|
||||
const panelExpandDepth = computed({
|
||||
get: () => props.expandDepth ?? internalExpandDepth.value,
|
||||
set: (value: number) => {
|
||||
if (props.expandDepth === undefined) {
|
||||
internalExpandDepth.value = value
|
||||
}
|
||||
emit('update:expandDepth', value)
|
||||
},
|
||||
})
|
||||
|
||||
const panelCopied = computed(() => props.copied ?? internalCopied.value)
|
||||
|
||||
const toggleExpand = () => {
|
||||
if (props.expandDisabled) return
|
||||
panelExpandDepth.value = panelExpandDepth.value === 0 ? 999 : 0
|
||||
}
|
||||
|
||||
const copyJson = () => {
|
||||
if (props.copyDisabled) return
|
||||
if (props.customCopy) {
|
||||
emit('copy')
|
||||
return
|
||||
}
|
||||
if (props.data == null) return
|
||||
|
||||
copyToClipboard(JSON.stringify(props.data, null, 2), false)
|
||||
internalCopied.value = true
|
||||
window.setTimeout(() => {
|
||||
internalCopied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.json-content-panel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--card));
|
||||
box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
}
|
||||
|
||||
.json-panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.35rem 0.55rem 0.35rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: hsl(var(--muted) / 0.55);
|
||||
}
|
||||
|
||||
.json-panel-title {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.json-panel-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.json-panel-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.45rem;
|
||||
height: 1.45rem;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.json-panel-action:hover {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.json-panel-action.is-disabled,
|
||||
.json-panel-action:disabled {
|
||||
color: hsl(var(--muted-foreground) / 0.4);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.json-panel-action.is-disabled:hover,
|
||||
.json-panel-action:disabled:hover {
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground) / 0.4);
|
||||
}
|
||||
|
||||
.json-panel-content :deep(.json-viewer) {
|
||||
max-height: v-bind(maxHeight);
|
||||
}
|
||||
|
||||
.json-panel-content :deep(.bg-muted\/30) {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -457,9 +457,21 @@
|
||||
</div>
|
||||
|
||||
<!-- Tab 内容(统一容器) -->
|
||||
<div class="content-block rounded-md border overflow-hidden">
|
||||
<!-- 表头栏:工具按钮 -->
|
||||
<div class="flex items-center justify-end gap-0.5 px-3 py-1 border-b bg-muted/40">
|
||||
<JsonContentPanel
|
||||
class="content-block"
|
||||
:title="activeJsonPanelTitle"
|
||||
:data="activeJsonPanelData"
|
||||
:is-dark="isDark"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:copied="Boolean(copiedStates[activeTab])"
|
||||
:custom-copy="true"
|
||||
:expand-disabled="viewMode === 'compare' || (supportsConversationView && contentViewMode === 'conversation')"
|
||||
:copy-disabled="viewMode === 'compare'"
|
||||
max-height="500px"
|
||||
@update:expand-depth="currentExpandDepth = $event"
|
||||
@copy="copyContent(activeTab)"
|
||||
>
|
||||
<template #toolbar-actions-before>
|
||||
<!-- 区域1:条件性按钮(cURL、视图切换、对比) -->
|
||||
<!-- cURL 复制(仅在请求头/请求体 Tab) -->
|
||||
<template v-if="['request-headers', 'request-body'].includes(activeTab)">
|
||||
@@ -538,47 +550,7 @@
|
||||
|
||||
<!-- 区域3:常驻按钮(展开/收缩、复制) -->
|
||||
<div class="w-px h-3.5 bg-border mx-0.5" />
|
||||
|
||||
<!-- 展开/收缩 -->
|
||||
<button
|
||||
:title="currentExpandDepth === 0 ? '展开全部' : '收缩全部'"
|
||||
class="p-1 rounded transition-colors"
|
||||
:class="viewMode === 'compare' || (supportsConversationView && contentViewMode === 'conversation')
|
||||
? 'text-muted-foreground/40 cursor-not-allowed'
|
||||
: 'text-muted-foreground hover:bg-muted'"
|
||||
:disabled="viewMode === 'compare' || (supportsConversationView && contentViewMode === 'conversation')"
|
||||
@click="currentExpandDepth === 0 ? expandAll() : collapseAll()"
|
||||
>
|
||||
<Maximize2
|
||||
v-if="currentExpandDepth === 0"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
<Minimize2
|
||||
v-else
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- 复制 -->
|
||||
<button
|
||||
:title="copiedStates[activeTab] ? '已复制' : '复制'"
|
||||
class="p-1 rounded transition-colors"
|
||||
:class="viewMode === 'compare'
|
||||
? 'text-muted-foreground/40 cursor-not-allowed'
|
||||
: 'text-muted-foreground hover:bg-muted'"
|
||||
:disabled="viewMode === 'compare'"
|
||||
@click="copyContent(activeTab)"
|
||||
>
|
||||
<Check
|
||||
v-if="copiedStates[activeTab]"
|
||||
class="w-3.5 h-3.5 text-green-500"
|
||||
/>
|
||||
<Copy
|
||||
v-else
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<TabsContent value="request-headers">
|
||||
<RequestHeadersContent
|
||||
:detail="detail"
|
||||
@@ -672,7 +644,7 @@
|
||||
empty-message="无元数据信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</JsonContentPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -703,7 +675,7 @@ import Separator from '@/components/ui/separator.vue'
|
||||
import Skeleton from '@/components/ui/skeleton.vue'
|
||||
import Tabs from '@/components/ui/tabs.vue'
|
||||
import TabsContent from '@/components/ui/tabs-content.vue'
|
||||
import { Copy, Check, Maximize2, Minimize2, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
|
||||
import { Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
|
||||
import { dashboardApi, type RequestDetail } from '@/api/dashboard'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { formatShortRequestId } from '@/utils/format'
|
||||
@@ -719,6 +691,7 @@ import {
|
||||
// 子组件
|
||||
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
|
||||
import JsonContent from './RequestDetailDrawer/JsonContent.vue'
|
||||
import JsonContentPanel from './JsonContentPanel.vue'
|
||||
import ConversationView from './RequestDetailDrawer/ConversationView.vue'
|
||||
import HorizontalRequestTimeline from './HorizontalRequestTimeline.vue'
|
||||
import ReplayDialog from './ReplayDialog.vue'
|
||||
@@ -1071,6 +1044,29 @@ const currentHeaderData = computed(() => {
|
||||
return detail.value.provider_request_headers
|
||||
})
|
||||
|
||||
const activeJsonPanelData = computed(() => {
|
||||
switch (activeTab.value) {
|
||||
case 'request-headers':
|
||||
return currentHeaderData.value
|
||||
case 'request-body':
|
||||
return currentRequestBody.value
|
||||
case 'response-headers':
|
||||
return currentResponseHeaderData.value
|
||||
case 'response-body':
|
||||
return currentResponseBody.value
|
||||
case 'metadata':
|
||||
return metadataPanelData.value
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const activeJsonPanelTitle = computed(() => {
|
||||
if (viewMode.value === 'compare') return '对比'
|
||||
if (supportsConversationView.value && contentViewMode.value === 'conversation') return 'Chat'
|
||||
return 'JSON'
|
||||
})
|
||||
|
||||
// 请求体渲染结果
|
||||
const requestRenderResult = computed<RenderResult>(() => {
|
||||
const body = currentRequestBody.value
|
||||
@@ -2045,14 +2041,6 @@ function toggleContentView() {
|
||||
}
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
currentExpandDepth.value = 999
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
currentExpandDepth.value = 0
|
||||
}
|
||||
|
||||
// 复制 cURL 命令
|
||||
async function copyCurlCommand() {
|
||||
if (!props.requestId || curlCopying.value) return
|
||||
|
||||
Reference in New Issue
Block a user