mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: 请求记录 cURL 导出与回放功能
- 后端新增 /{usage_id}/curl 接口,重建完整 cURL 命令(含明文 API Key)
- 后端新增 /{usage_id}/replay 接口,支持向原始或指定提供商回放请求
- 回放支持 OAuth/Vertex AI/API Key 多种认证方式和跨格式请求体转换
- 前端新增 ReplayDialog 组件,支持选择目标提供商/Key 并查看响应
- RequestDetailDrawer 添加回放按钮和 cURL 复制按钮
- 调整 Tab 内容区布局,表头栏与内容区融为一体
This commit is contained in:
475
frontend/src/features/usage/components/ReplayDialog.vue
Normal file
475
frontend/src/features/usage/components/ReplayDialog.vue
Normal file
@@ -0,0 +1,475 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/30 backdrop-blur-sm"
|
||||
@click="handleClose"
|
||||
/>
|
||||
<Card class="relative w-full max-w-6xl max-h-[85vh] min-h-[60vh] mx-4 shadow-2xl flex flex-col">
|
||||
<!-- 头部:标题 + 提供商/Key 选择 + 发送 -->
|
||||
<div class="px-4 py-2.5 border-b flex items-center gap-3 shrink-0 flex-wrap">
|
||||
<h3 class="text-sm font-semibold shrink-0">
|
||||
请求回放
|
||||
</h3>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
class="h-4"
|
||||
/>
|
||||
|
||||
<!-- 提供商选择 -->
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<label class="text-xs text-muted-foreground shrink-0">提供商</label>
|
||||
<select
|
||||
v-model="selectedProviderId"
|
||||
class="h-7 rounded-md border border-input bg-background px-2 text-xs min-w-[140px]"
|
||||
:disabled="replaying"
|
||||
@change="onProviderChange"
|
||||
>
|
||||
<option value="">
|
||||
原始 ({{ detail?.provider || '-' }})
|
||||
</option>
|
||||
<option
|
||||
v-for="p in providers"
|
||||
:key="p.id"
|
||||
:value="p.id"
|
||||
>
|
||||
{{ p.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Key 选择 -->
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<label class="text-xs text-muted-foreground shrink-0">Key</label>
|
||||
<select
|
||||
v-model="selectedKeyId"
|
||||
class="h-7 rounded-md border border-input bg-background px-2 text-xs min-w-[140px]"
|
||||
:disabled="replaying || loadingKeys"
|
||||
>
|
||||
<option value="">
|
||||
{{ loadingKeys ? '加载中...' : selectedProviderId ? '自动选择' : '原始 Key' }}
|
||||
</option>
|
||||
<option
|
||||
v-for="k in keys"
|
||||
:key="k.id"
|
||||
:value="k.id"
|
||||
>
|
||||
{{ k.name }} ({{ k.api_key_masked }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:发送 + 关闭 -->
|
||||
<div class="flex items-center gap-1 ml-auto shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="replaying"
|
||||
class="gap-1.5 h-7 text-xs"
|
||||
@click="doReplay"
|
||||
>
|
||||
<Loader2
|
||||
v-if="replaying"
|
||||
class="w-3.5 h-3.5 animate-spin"
|
||||
/>
|
||||
<Play
|
||||
v-else
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
{{ replaying ? '请求中...' : '发送' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="handleClose"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 双栏内容区 -->
|
||||
<div class="flex-1 min-h-0 flex">
|
||||
<!-- ===== 左栏:请求 ===== -->
|
||||
<div class="w-1/2 flex flex-col min-h-0 border-r">
|
||||
<!-- 左栏头 -->
|
||||
<div class="px-4 py-1.5 border-b bg-muted/30 flex items-center justify-between shrink-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-xs font-medium text-muted-foreground">请求</span>
|
||||
<span
|
||||
v-if="detail?.model"
|
||||
class="text-[11px] text-muted-foreground/60 font-mono truncate"
|
||||
>{{ detail.model }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="p-1 rounded transition-colors text-muted-foreground hover:bg-muted shrink-0"
|
||||
:title="requestCopied ? '已复制' : '复制请求体'"
|
||||
@click="copyRequestBody"
|
||||
>
|
||||
<Check
|
||||
v-if="requestCopied"
|
||||
class="w-3 h-3 text-green-500"
|
||||
/>
|
||||
<Copy
|
||||
v-else
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 左栏内容 -->
|
||||
<div class="flex-1 overflow-y-auto scrollbar-stable">
|
||||
<!-- 请求头(可折叠) -->
|
||||
<div class="border-b">
|
||||
<button
|
||||
class="w-full px-4 py-1.5 flex items-center gap-1.5 text-xs text-muted-foreground hover:bg-muted/30 transition-colors"
|
||||
@click="showRequestHeaders = !showRequestHeaders"
|
||||
>
|
||||
<ChevronRight
|
||||
class="w-3 h-3 transition-transform shrink-0"
|
||||
:class="{ 'rotate-90': showRequestHeaders }"
|
||||
/>
|
||||
<span class="font-medium">Headers</span>
|
||||
<span class="text-muted-foreground/60 ml-0.5">({{ requestHeaderCount }})</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="showRequestHeaders && hasRequestHeaders"
|
||||
class="px-4 pb-2.5"
|
||||
>
|
||||
<div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-[11px] font-mono">
|
||||
<template
|
||||
v-for="(value, key) in displayRequestHeaders"
|
||||
:key="key"
|
||||
>
|
||||
<span class="text-muted-foreground/70 text-right select-none">{{ key }}</span>
|
||||
<span class="break-all">{{ value }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 请求体 -->
|
||||
<div class="border-b">
|
||||
<div class="px-4 py-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span class="w-3 h-3 shrink-0" />
|
||||
<span class="font-medium">Body</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<pre
|
||||
v-if="formattedRequestBody"
|
||||
class="text-xs font-mono whitespace-pre-wrap break-all leading-relaxed"
|
||||
>{{ formattedRequestBody }}</pre>
|
||||
<div
|
||||
v-else
|
||||
class="text-xs text-muted-foreground/50 italic"
|
||||
>
|
||||
无请求体
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 右栏:响应 ===== -->
|
||||
<div class="w-1/2 flex flex-col min-h-0">
|
||||
<!-- 右栏头 -->
|
||||
<div class="px-4 py-1.5 border-b bg-muted/30 flex items-center justify-between shrink-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-xs font-medium text-muted-foreground">响应</span>
|
||||
<template v-if="replayResult">
|
||||
<Badge
|
||||
:variant="replayResult.status_code < 400 ? 'success' : 'destructive'"
|
||||
class="text-[10px] px-1.5 py-0 h-4"
|
||||
>
|
||||
{{ replayResult.status_code }}
|
||||
</Badge>
|
||||
<span class="text-[11px] text-muted-foreground/60">{{ replayResult.response_time_ms }}ms</span>
|
||||
<span class="text-[11px] text-muted-foreground/60 font-mono truncate">{{ replayResult.provider }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
v-if="replayResult"
|
||||
class="p-1 rounded transition-colors text-muted-foreground hover:bg-muted shrink-0"
|
||||
:title="responseCopied ? '已复制' : '复制响应体'"
|
||||
@click="copyResponseBody"
|
||||
>
|
||||
<Check
|
||||
v-if="responseCopied"
|
||||
class="w-3 h-3 text-green-500"
|
||||
/>
|
||||
<Copy
|
||||
v-else
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 右栏内容 -->
|
||||
<div class="flex-1 overflow-y-auto scrollbar-stable">
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="!replayResult && !replayError && !replaying"
|
||||
class="flex flex-col items-center justify-center h-full text-muted-foreground/40 gap-2"
|
||||
>
|
||||
<Play class="w-8 h-8" />
|
||||
<span class="text-xs">点击发送查看响应</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div
|
||||
v-else-if="replaying && !replayResult"
|
||||
class="flex items-center justify-center h-full"
|
||||
>
|
||||
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<!-- 错误 -->
|
||||
<div
|
||||
v-else-if="replayError"
|
||||
class="px-4 py-4"
|
||||
>
|
||||
<div class="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ replayError }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 响应结果 -->
|
||||
<template v-if="replayResult">
|
||||
<!-- 响应头(可折叠) -->
|
||||
<div class="border-b">
|
||||
<button
|
||||
class="w-full px-4 py-1.5 flex items-center gap-1.5 text-xs text-muted-foreground hover:bg-muted/30 transition-colors"
|
||||
@click="showResponseHeaders = !showResponseHeaders"
|
||||
>
|
||||
<ChevronRight
|
||||
class="w-3 h-3 transition-transform shrink-0"
|
||||
:class="{ 'rotate-90': showResponseHeaders }"
|
||||
/>
|
||||
<span class="font-medium">Headers</span>
|
||||
<span class="text-muted-foreground/60 ml-0.5">({{ responseHeaderCount }})</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="showResponseHeaders"
|
||||
class="px-4 pb-2.5"
|
||||
>
|
||||
<div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-[11px] font-mono">
|
||||
<template
|
||||
v-for="(value, key) in replayResult.response_headers"
|
||||
:key="key"
|
||||
>
|
||||
<span class="text-muted-foreground/70 text-right select-none">{{ key }}</span>
|
||||
<span class="break-all">{{ value }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 响应体 -->
|
||||
<div class="border-b">
|
||||
<div class="px-4 py-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span class="w-3 h-3 shrink-0" />
|
||||
<span class="font-medium">Body</span>
|
||||
<span
|
||||
v-if="replayResult.url"
|
||||
class="text-muted-foreground/40 font-mono text-[10px] truncate ml-1"
|
||||
>{{ replayResult.url }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<pre class="text-xs font-mono whitespace-pre-wrap break-all leading-relaxed">{{ formattedResponseBody }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { dashboardApi, type ReplayResponse, type RequestDetail } from '@/api/dashboard'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getProviderKeys } from '@/api/endpoints/keys'
|
||||
import type { EndpointAPIKey } from '@/api/endpoints/types'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Separator from '@/components/ui/separator.vue'
|
||||
import { X, Play, Loader2, ChevronRight, Copy, Check } from 'lucide-vue-next'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
interface ProviderOption {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
requestId: string | null
|
||||
detail: RequestDetail | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const selectedProviderId = ref('')
|
||||
const selectedKeyId = ref('')
|
||||
const providers = ref<ProviderOption[]>([])
|
||||
const keys = ref<EndpointAPIKey[]>([])
|
||||
const loadingKeys = ref(false)
|
||||
const replaying = ref(false)
|
||||
const replayResult = ref<ReplayResponse | null>(null)
|
||||
const replayError = ref<string | null>(null)
|
||||
const showRequestHeaders = ref(false)
|
||||
const showResponseHeaders = ref(false)
|
||||
const requestCopied = ref(false)
|
||||
const responseCopied = ref(false)
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// ---- 请求侧数据 ----
|
||||
|
||||
const displayRequestHeaders = computed(() => {
|
||||
if (!props.detail) return {}
|
||||
// 优先显示发送给提供商的请求头,否则显示客户端请求头
|
||||
return props.detail.provider_request_headers || props.detail.request_headers || {}
|
||||
})
|
||||
|
||||
const hasRequestHeaders = computed(() => {
|
||||
return Object.keys(displayRequestHeaders.value).length > 0
|
||||
})
|
||||
|
||||
const requestHeaderCount = computed(() => {
|
||||
return Object.keys(displayRequestHeaders.value).length
|
||||
})
|
||||
|
||||
const formattedRequestBody = computed(() => {
|
||||
if (!props.detail?.request_body) return ''
|
||||
try {
|
||||
return JSON.stringify(props.detail.request_body, null, 2)
|
||||
} catch {
|
||||
return String(props.detail.request_body)
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 响应侧数据 ----
|
||||
|
||||
const formattedResponseBody = computed(() => {
|
||||
if (!replayResult.value?.response_body) return ''
|
||||
try {
|
||||
return JSON.stringify(replayResult.value.response_body, null, 2)
|
||||
} catch {
|
||||
return String(replayResult.value.response_body)
|
||||
}
|
||||
})
|
||||
|
||||
const responseHeaderCount = computed(() => {
|
||||
if (!replayResult.value?.response_headers) return 0
|
||||
return Object.keys(replayResult.value.response_headers).length
|
||||
})
|
||||
|
||||
// ---- 生命周期 ----
|
||||
|
||||
watch(() => props.isOpen, async (isOpen) => {
|
||||
if (isOpen) {
|
||||
replayResult.value = null
|
||||
replayError.value = null
|
||||
selectedProviderId.value = ''
|
||||
selectedKeyId.value = ''
|
||||
keys.value = []
|
||||
showRequestHeaders.value = false
|
||||
showResponseHeaders.value = false
|
||||
try {
|
||||
const summary = await getProvidersSummary()
|
||||
providers.value = summary
|
||||
.filter(p => p.is_active && p.active_endpoints > 0)
|
||||
.map(p => ({ id: p.id, name: p.name }))
|
||||
} catch (e) {
|
||||
log.error('Failed to load providers:', e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function onProviderChange() {
|
||||
selectedKeyId.value = ''
|
||||
keys.value = []
|
||||
if (!selectedProviderId.value) return
|
||||
|
||||
loadingKeys.value = true
|
||||
try {
|
||||
const allKeys = await getProviderKeys(selectedProviderId.value)
|
||||
keys.value = allKeys.filter(k => k.health_score > 0 || allKeys.length <= 3)
|
||||
if (keys.value.length === 0) keys.value = allKeys
|
||||
} catch (e) {
|
||||
log.error('Failed to load keys:', e)
|
||||
} finally {
|
||||
loadingKeys.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doReplay() {
|
||||
if (!props.requestId || replaying.value) return
|
||||
|
||||
replaying.value = true
|
||||
replayError.value = null
|
||||
replayResult.value = null
|
||||
|
||||
try {
|
||||
const params: Record<string, string> = {}
|
||||
if (selectedProviderId.value) params.provider_id = selectedProviderId.value
|
||||
if (selectedKeyId.value) params.api_key_id = selectedKeyId.value
|
||||
|
||||
replayResult.value = await dashboardApi.replayRequest(
|
||||
props.requestId,
|
||||
Object.keys(params).length > 0 ? params : undefined,
|
||||
)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { detail?: string } }; message?: string }
|
||||
replayError.value = err?.response?.data?.detail || err?.message || '请求失败'
|
||||
log.error('Replay failed:', e)
|
||||
} finally {
|
||||
replaying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyRequestBody() {
|
||||
if (!formattedRequestBody.value) return
|
||||
copyToClipboard(formattedRequestBody.value, false)
|
||||
requestCopied.value = true
|
||||
setTimeout(() => { requestCopied.value = false }, 2000)
|
||||
}
|
||||
|
||||
function copyResponseBody() {
|
||||
if (!replayResult.value) return
|
||||
copyToClipboard(formattedResponseBody.value, false)
|
||||
responseCopied.value = true
|
||||
setTimeout(() => { responseCopied.value = false }, 2000)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
useEscapeKey(() => {
|
||||
if (props.isOpen) handleClose()
|
||||
}, { disableOnInput: true, once: false })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -62,6 +62,16 @@
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="回放请求"
|
||||
:disabled="loading"
|
||||
@click="openReplayDialog"
|
||||
>
|
||||
<Play class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -437,10 +447,6 @@
|
||||
>
|
||||
<Server class="w-4 h-4" />
|
||||
</button>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
class="h-4 mx-1"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 请求体/响应体专用:JSON/对话 视图切换(单按钮) -->
|
||||
@@ -463,16 +469,38 @@
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
class="h-4 mx-1"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- cURL 复制(仅在请求头/请求体 Tab) -->
|
||||
<template v-if="['request-headers', 'request-body'].includes(activeTab)">
|
||||
<button
|
||||
:title="curlCopied ? '已复制 cURL' : '复制 cURL'"
|
||||
class="p-1.5 rounded transition-colors text-muted-foreground hover:bg-muted"
|
||||
:disabled="curlCopying"
|
||||
@click="copyCurlCommand"
|
||||
>
|
||||
<Check
|
||||
v-if="curlCopied"
|
||||
class="w-4 h-4 text-green-500"
|
||||
/>
|
||||
<Terminal
|
||||
v-else
|
||||
class="w-4 h-4"
|
||||
:class="{ 'animate-pulse': curlCopying }"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</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">
|
||||
<!-- 展开/收缩 -->
|
||||
<button
|
||||
:title="currentExpandDepth === 0 ? '展开全部' : '收缩全部'"
|
||||
class="p-1.5 rounded transition-colors"
|
||||
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'"
|
||||
@@ -481,17 +509,17 @@
|
||||
>
|
||||
<Maximize2
|
||||
v-if="currentExpandDepth === 0"
|
||||
class="w-4 h-4"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
<Minimize2
|
||||
v-else
|
||||
class="w-4 h-4"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</button>
|
||||
<!-- 复制 -->
|
||||
<button
|
||||
:title="copiedStates[activeTab] ? '已复制' : '复制'"
|
||||
class="p-1.5 rounded transition-colors"
|
||||
class="p-1 rounded transition-colors"
|
||||
:class="viewMode === 'compare'
|
||||
? 'text-muted-foreground/40 cursor-not-allowed'
|
||||
: 'text-muted-foreground hover:bg-muted'"
|
||||
@@ -500,87 +528,86 @@
|
||||
>
|
||||
<Check
|
||||
v-if="copiedStates[activeTab]"
|
||||
class="w-4 h-4 text-green-500"
|
||||
class="w-3.5 h-3.5 text-green-500"
|
||||
/>
|
||||
<Copy
|
||||
v-else
|
||||
class="w-4 h-4"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TabsContent value="request-headers">
|
||||
<RequestHeadersContent
|
||||
:detail="detail"
|
||||
:view-mode="viewMode"
|
||||
:data-source="dataSource"
|
||||
:current-header-data="currentHeaderData"
|
||||
:current-expand-depth="currentExpandDepth"
|
||||
:has-provider-headers="hasProviderHeaders"
|
||||
:client-headers-with-diff="clientHeadersWithDiff"
|
||||
:provider-headers-with-diff="providerHeadersWithDiff"
|
||||
:header-stats="headerStats"
|
||||
:is-dark="isDark"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="request-body">
|
||||
<!-- 对话视图 -->
|
||||
<ConversationView
|
||||
v-if="contentViewMode === 'conversation'"
|
||||
:render-result="requestRenderResult"
|
||||
empty-message="无请求体信息"
|
||||
/>
|
||||
<!-- JSON 视图 -->
|
||||
<JsonContent
|
||||
v-else
|
||||
:data="detail.request_body"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无请求体信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="response-headers">
|
||||
<JsonContent
|
||||
:data="actualResponseHeaders"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无响应头信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="response-body">
|
||||
<!-- 对话视图 -->
|
||||
<ConversationView
|
||||
v-if="contentViewMode === 'conversation'"
|
||||
:render-result="responseRenderResult"
|
||||
empty-message="无响应体信息"
|
||||
/>
|
||||
<!-- JSON 视图 -->
|
||||
<JsonContent
|
||||
v-else
|
||||
:data="detail.response_body"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无响应体信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="metadata">
|
||||
<JsonContent
|
||||
:data="detail.metadata"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无元数据信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
<!-- Tab 内容 -->
|
||||
<TabsContent value="request-headers">
|
||||
<RequestHeadersContent
|
||||
:detail="detail"
|
||||
:view-mode="viewMode"
|
||||
:data-source="dataSource"
|
||||
:current-header-data="currentHeaderData"
|
||||
:current-expand-depth="currentExpandDepth"
|
||||
:has-provider-headers="hasProviderHeaders"
|
||||
:client-headers-with-diff="clientHeadersWithDiff"
|
||||
:provider-headers-with-diff="providerHeadersWithDiff"
|
||||
:header-stats="headerStats"
|
||||
:is-dark="isDark"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="request-body">
|
||||
<!-- 对话视图 -->
|
||||
<ConversationView
|
||||
v-if="contentViewMode === 'conversation'"
|
||||
:render-result="requestRenderResult"
|
||||
empty-message="无请求体信息"
|
||||
/>
|
||||
<!-- JSON 视图 -->
|
||||
<JsonContent
|
||||
v-else
|
||||
:data="detail.request_body"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无请求体信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="response-headers">
|
||||
<JsonContent
|
||||
:data="actualResponseHeaders"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无响应头信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="response-body">
|
||||
<!-- 对话视图 -->
|
||||
<ConversationView
|
||||
v-if="contentViewMode === 'conversation'"
|
||||
:render-result="responseRenderResult"
|
||||
empty-message="无响应体信息"
|
||||
/>
|
||||
<!-- JSON 视图 -->
|
||||
<JsonContent
|
||||
v-else
|
||||
:data="detail.response_body"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无响应体信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="metadata">
|
||||
<JsonContent
|
||||
:data="detail.metadata"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无元数据信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -590,6 +617,14 @@
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 请求回放对话框 -->
|
||||
<ReplayDialog
|
||||
:is-open="replayDialogOpen"
|
||||
:request-id="requestId"
|
||||
:detail="detail"
|
||||
@close="replayDialogOpen = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -603,7 +638,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 } from 'lucide-vue-next'
|
||||
import { Copy, Check, Maximize2, Minimize2, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
|
||||
import { dashboardApi, type RequestDetail } from '@/api/dashboard'
|
||||
import { API_FORMAT_LABELS } from '@/api/endpoints/types'
|
||||
import { log } from '@/utils/logger'
|
||||
@@ -613,6 +648,7 @@ import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.v
|
||||
import JsonContent from './RequestDetailDrawer/JsonContent.vue'
|
||||
import ConversationView from './RequestDetailDrawer/ConversationView.vue'
|
||||
import HorizontalRequestTimeline from './HorizontalRequestTimeline.vue'
|
||||
import ReplayDialog from './ReplayDialog.vue'
|
||||
|
||||
// 对话解析器
|
||||
import {
|
||||
@@ -651,6 +687,9 @@ const historicalPricing = ref<{
|
||||
} | null>(null)
|
||||
const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
const autoRefreshing = ref(false)
|
||||
const curlCopying = ref(false)
|
||||
const curlCopied = ref(false)
|
||||
const replayDialogOpen = ref(false)
|
||||
|
||||
// 监听标签页切换
|
||||
watch(activeTab, (newTab) => {
|
||||
@@ -1209,6 +1248,29 @@ function collapseAll() {
|
||||
currentExpandDepth.value = 0
|
||||
}
|
||||
|
||||
// 复制 cURL 命令
|
||||
async function copyCurlCommand() {
|
||||
if (!props.requestId || curlCopying.value) return
|
||||
curlCopying.value = true
|
||||
try {
|
||||
const data = await dashboardApi.getCurlData(props.requestId)
|
||||
if (data.curl) {
|
||||
copyToClipboard(data.curl, false)
|
||||
curlCopied.value = true
|
||||
setTimeout(() => { curlCopied.value = false }, 2000)
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Failed to generate cURL command:', err)
|
||||
} finally {
|
||||
curlCopying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开请求回放对话框
|
||||
function openReplayDialog() {
|
||||
replayDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 请求头合并对比逻辑
|
||||
interface HeaderEntry {
|
||||
key: string
|
||||
@@ -1348,6 +1410,13 @@ useEscapeKey(() => {
|
||||
.drawer-leave-from .relative {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* 内容区融合:子组件的 Card 不再需要自己的边框和圆角,与表头栏融为一体 */
|
||||
.content-block :deep(.rounded-2xl) {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user