feat(usage,pool,codex): 请求详情 body 按需加载、配额选择器重构与 Codex body rules 修正

- usage 详情 API 新增 include_bodies 参数,支持跳过 body 内容返回 has_*_body 标记
- 前端请求详情抽屉首次加载不含 body,切换到 body tab 时延迟加载并展示 skeleton
- Timeline 组件延迟 120ms 挂载,避免阻塞抽屉渲染
- 提取号池配额判断逻辑到 quota-selectors 工具模块并添加单测
- Codex 移除 openai:compact 的默认 body rules 注册
- ModelTestDialog/TestResultDialog 模板格式化
This commit is contained in:
fawney19
2026-03-06 15:40:11 +08:00
parent d97ec3fde2
commit bdccfa6e78
10 changed files with 395 additions and 100 deletions

View File

@@ -176,6 +176,10 @@ export interface RequestDetail {
client_response_headers?: Record<string, unknown>
response_body?: Record<string, unknown>
client_response_body?: Record<string, unknown>
has_request_body?: boolean
has_provider_request_body?: boolean
has_response_body?: boolean
has_client_response_body?: boolean
metadata?: Record<string, unknown>
// 阶梯计费信息
tiered_pricing?: {
@@ -327,8 +331,10 @@ export const dashboardApi = {
// 获取请求详情
// NOTE: This method now calls the new RESTful API at /api/admin/usage/{id}
async getRequestDetail(requestId: string): Promise<RequestDetail> {
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`)
async getRequestDetail(requestId: string, options: { includeBodies?: boolean } = {}): Promise<RequestDetail> {
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
params: { include_bodies: options.includeBodies ?? true },
})
return response.data
},

View File

@@ -270,6 +270,7 @@ import { listPoolKeys, type PoolKeyDetail } from '@/api/endpoints/pool'
import { deleteEndpointKey, refreshProviderQuota, updateProviderKey } from '@/api/endpoints/keys'
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import { hasNoFiveHourLimit as hasNoFiveHourLimitByQuota, hasNoWeeklyLimit as hasNoWeeklyLimitByQuota } from '@/features/pool/utils/quota-selectors'
type QuickSelectorValue =
| 'banned'
@@ -410,31 +411,12 @@ function isBannedKey(key: PoolKeyDetail): boolean {
return false
}
function getQuotaSegments(accountQuota: string | null | undefined): string[] {
return String(accountQuota || '')
.split('|')
.map((segment) => normalizeText(segment))
.filter(Boolean)
function hasNoFiveHourQuota(key: PoolKeyDetail): boolean {
return hasNoFiveHourLimitByQuota(key.account_quota)
}
function isDepletedQuotaSegment(segment: string): boolean {
if (/(无额度|额度不足|已耗尽|耗尽|depleted|exhausted|insufficient)/.test(segment)) return true
if (/剩余\s*0(\.0+)?/.test(segment)) return true
if (/\b0(\.0+)?\s*\/\s*\d/.test(segment)) return true
if (/\b0(\.0+)?%/.test(segment)) return true
return false
}
function hasNoFiveHourLimit(key: PoolKeyDetail): boolean {
return getQuotaSegments(key.account_quota)
.filter((segment) => /5h|5小时/.test(segment))
.some(isDepletedQuotaSegment)
}
function hasNoWeeklyLimit(key: PoolKeyDetail): boolean {
return getQuotaSegments(key.account_quota)
.filter((segment) => /周|weekly|week/.test(segment))
.some(isDepletedQuotaSegment)
function hasNoWeeklyQuota(key: PoolKeyDetail): boolean {
return hasNoWeeklyLimitByQuota(key.account_quota)
}
function isOAuthInvalid(key: PoolKeyDetail): boolean {
@@ -474,8 +456,8 @@ function toggleSelectFiltered(checked: boolean | 'indeterminate'): void {
function matchesSelector(key: PoolKeyDetail, selector: QuickSelectorValue): boolean {
if (selector === 'banned') return isBannedKey(key)
if (selector === 'no_5h_limit') return hasNoFiveHourLimit(key)
if (selector === 'no_weekly_limit') return hasNoWeeklyLimit(key)
if (selector === 'no_5h_limit') return hasNoFiveHourQuota(key)
if (selector === 'no_weekly_limit') return hasNoWeeklyQuota(key)
if (selector === 'plan_free') return isFreePlan(key)
if (selector === 'plan_team') return isTeamPlan(key)
if (selector === 'oauth_invalid') return isOAuthInvalid(key)

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
getQuotaSegments,
hasNoFiveHourLimit,
hasNoWeeklyLimit,
isDepletedQuotaSegment,
} from '../quota-selectors'
describe('quota selectors', () => {
it('splits quota text into normalized segments', () => {
expect(getQuotaSegments('周剩余 0.0% | 5H剩余 100.0')).toEqual([
'周剩余 0.0%',
'5h剩余 100.0%',
])
})
it('treats exact 0 percent as depleted', () => {
expect(isDepletedQuotaSegment('周剩余 0.0%5天后重置')).toBe(true)
expect(isDepletedQuotaSegment('5h剩余 0%')).toBe(true)
})
it('does not treat non-zero decimal percentages as depleted', () => {
expect(isDepletedQuotaSegment('周45.0% 5d20h')).toBe(false)
expect(isDepletedQuotaSegment('周17.0% 5d20h')).toBe(false)
expect(isDepletedQuotaSegment('5h93.0% 2h')).toBe(false)
})
it('detects only depleted weekly segments', () => {
expect(hasNoWeeklyLimit('周剩余 0.0%5天后重置 | 5H剩余 93.0%2小时后重置')).toBe(true)
expect(hasNoWeeklyLimit('周剩余 45.0%5天后重置 | 5H剩余 0.0%2小时后重置')).toBe(false)
})
it('detects only depleted 5h segments', () => {
expect(hasNoFiveHourLimit('周剩余 45.0%5天后重置 | 5H剩余 0.0%2小时后重置')).toBe(true)
expect(hasNoFiveHourLimit('周剩余 0.0%5天后重置 | 5H剩余 93.0%2小时后重置')).toBe(false)
})
})

View File

@@ -0,0 +1,51 @@
export function normalizeQuotaSegment(value: string | null | undefined): string {
return String(value || '')
.trim()
.toLowerCase()
.replace(//g, '%')
}
export function getQuotaSegments(accountQuota: string | null | undefined): string[] {
return String(accountQuota || '')
.split('|')
.map((segment) => normalizeQuotaSegment(segment))
.filter(Boolean)
}
function hasDepletedKeyword(segment: string): boolean {
return /(无额度|额度不足|已耗尽|耗尽|depleted|exhausted|insufficient)/.test(segment)
}
function hasZeroRemainingText(segment: string): boolean {
return /剩余\s*0(?:\.0+)?(?!\d)/.test(segment)
}
function hasZeroRatio(segment: string): boolean {
return [...segment.matchAll(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/g)]
.some(([, used, total]) => Number(used) === 0 && Number(total) > 0)
}
function hasZeroPercent(segment: string): boolean {
return [...segment.matchAll(/(\d+(?:\.\d+)?)\s*%/g)]
.some(([, percent]) => Number(percent) === 0)
}
export function isDepletedQuotaSegment(segment: string): boolean {
if (hasDepletedKeyword(segment)) return true
if (hasZeroRemainingText(segment)) return true
if (hasZeroRatio(segment)) return true
if (hasZeroPercent(segment)) return true
return false
}
export function hasNoFiveHourLimit(accountQuota: string | null | undefined): boolean {
return getQuotaSegments(accountQuota)
.filter((segment) => /5h|5小时/.test(segment))
.some(isDepletedQuotaSegment)
}
export function hasNoWeeklyLimit(accountQuota: string | null | undefined): boolean {
return getQuotaSegments(accountQuota)
.filter((segment) => /周|weekly|week/.test(segment))
.some(isDepletedQuotaSegment)
}

View File

@@ -19,10 +19,16 @@
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-medium">{{ formatApiFormat(endpoint.api_format) }}</div>
<div class="mt-1 text-xs text-muted-foreground truncate">{{ endpoint.base_url }}</div>
<div class="text-sm font-medium">
{{ formatApiFormat(endpoint.api_format) }}
</div>
<div class="mt-1 text-xs text-muted-foreground truncate">
{{ endpoint.base_url }}
</div>
</div>
<Badge variant="outline">{{ endpoint.is_active ? '已启用' : '已禁用' }}</Badge>
<Badge variant="outline">
{{ endpoint.is_active ? '已启用' : '已禁用' }}
</Badge>
</div>
</button>
<div
@@ -39,8 +45,12 @@
>
<Loader2 class="w-8 h-8 animate-spin text-primary" />
<div class="space-y-1">
<p class="text-sm font-medium">正在测试模型</p>
<p class="text-xs text-muted-foreground">{{ selectingModelName || '-' }}</p>
<p class="text-sm font-medium">
正在测试模型
</p>
<p class="text-xs text-muted-foreground">
{{ selectingModelName || '-' }}
</p>
<p
v-if="selectedEndpoint"
class="text-xs text-muted-foreground"
@@ -124,8 +134,15 @@
</div>
</div>
<div class="mt-1.5 space-y-0.5">
<div v-if="attempt.key_name" class="font-medium truncate">{{ attempt.key_name }}</div>
<div class="text-muted-foreground">{{ maskKey(attempt.key_id) }}</div>
<div
v-if="attempt.key_name"
class="font-medium truncate"
>
{{ attempt.key_name }}
</div>
<div class="text-muted-foreground">
{{ maskKey(attempt.key_id) }}
</div>
<div
v-if="hasEffectiveModel && attempt.effective_model"
class="text-muted-foreground"
@@ -149,24 +166,39 @@
>
<table class="w-full text-xs table-fixed">
<colgroup>
<col class="w-8" />
<col class="w-[22%]" />
<col v-if="hasEffectiveModel" class="w-[18%]" />
<col class="w-16" />
<col class="w-16" />
<col />
<col class="w-8">
<col class="w-[22%]">
<col
v-if="hasEffectiveModel"
class="w-[18%]"
>
<col class="w-16">
<col class="w-16">
<col>
</colgroup>
<thead>
<tr class="border-b bg-muted/30">
<th class="pl-3 pr-1 py-2 text-left font-medium">#</th>
<th class="px-3 py-2 text-left font-medium">Key</th>
<th class="pl-3 pr-1 py-2 text-left font-medium">
#
</th>
<th class="px-3 py-2 text-left font-medium">
Key
</th>
<th
v-if="hasEffectiveModel"
class="px-3 py-2 text-left font-medium"
>发送模型</th>
<th class="px-3 py-2 text-left font-medium">状态</th>
<th class="px-3 py-2 text-right font-medium">延迟</th>
<th class="px-3 py-2 text-left font-medium">详情</th>
>
发送模型
</th>
<th class="px-3 py-2 text-left font-medium">
状态
</th>
<th class="px-3 py-2 text-right font-medium">
延迟
</th>
<th class="px-3 py-2 text-left font-medium">
详情
</th>
</tr>
</thead>
<tbody>
@@ -176,7 +208,9 @@
class="border-b last:border-b-0 align-top"
:class="attemptRowClass(attempt.status)"
>
<td class="pl-3 pr-1 py-2 text-muted-foreground">{{ attempt.candidate_index }}</td>
<td class="pl-3 pr-1 py-2 text-muted-foreground">
{{ attempt.candidate_index }}
</td>
<td class="px-3 py-2">
<div
v-if="attempt.key_name"
@@ -185,7 +219,10 @@
>
{{ attempt.key_name }}
</div>
<div class="text-muted-foreground truncate" :title="attempt.key_id">
<div
class="text-muted-foreground truncate"
:title="attempt.key_id"
>
{{ maskKey(attempt.key_id) }}
</div>
</td>

View File

@@ -75,8 +75,15 @@
<code class="text-[11px] bg-muted px-1 py-0.5 rounded shrink-0">{{ attempt.endpoint_api_format }}</code>
</div>
<div class="mt-1.5 space-y-0.5">
<div v-if="attempt.key_name" class="font-medium truncate">{{ attempt.key_name }}</div>
<div class="text-muted-foreground">{{ maskKey(attempt.key_id) }}</div>
<div
v-if="attempt.key_name"
class="font-medium truncate"
>
{{ attempt.key_name }}
</div>
<div class="text-muted-foreground">
{{ maskKey(attempt.key_id) }}
</div>
<div
v-if="hasEffectiveModel && attempt.effective_model"
class="text-muted-foreground"
@@ -100,26 +107,43 @@
>
<table class="w-full text-xs table-fixed">
<colgroup>
<col class="w-8" />
<col class="w-[22%]" />
<col class="w-20" />
<col v-if="hasEffectiveModel" class="w-[16%]" />
<col class="w-16" />
<col class="w-16" />
<col />
<col class="w-8">
<col class="w-[22%]">
<col class="w-20">
<col
v-if="hasEffectiveModel"
class="w-[16%]"
>
<col class="w-16">
<col class="w-16">
<col>
</colgroup>
<thead>
<tr class="border-b bg-muted/30">
<th class="pl-3 pr-1 py-2 text-left font-medium">#</th>
<th class="px-3 py-2 text-left font-medium">Key</th>
<th class="px-3 py-2 text-left font-medium">端点</th>
<th class="pl-3 pr-1 py-2 text-left font-medium">
#
</th>
<th class="px-3 py-2 text-left font-medium">
Key
</th>
<th class="px-3 py-2 text-left font-medium">
端点
</th>
<th
v-if="hasEffectiveModel"
class="px-3 py-2 text-left font-medium"
>发送模型</th>
<th class="px-3 py-2 text-left font-medium">状态</th>
<th class="px-3 py-2 text-right font-medium">延迟</th>
<th class="px-3 py-2 text-left font-medium">详情</th>
>
发送模型
</th>
<th class="px-3 py-2 text-left font-medium">
状态
</th>
<th class="px-3 py-2 text-right font-medium">
延迟
</th>
<th class="px-3 py-2 text-left font-medium">
详情
</th>
</tr>
</thead>
<tbody>
@@ -129,7 +153,9 @@
class="border-b last:border-b-0 align-top"
:class="attemptRowClass(attempt.status)"
>
<td class="pl-3 pr-1 py-2 text-muted-foreground">{{ attempt.candidate_index }}</td>
<td class="pl-3 pr-1 py-2 text-muted-foreground">
{{ attempt.candidate_index }}
</td>
<td class="px-3 py-2">
<div
v-if="attempt.key_name"
@@ -138,7 +164,10 @@
>
{{ attempt.key_name }}
</div>
<div class="text-muted-foreground truncate" :title="attempt.key_id">
<div
class="text-muted-foreground truncate"
:title="attempt.key_id"
>
{{ maskKey(attempt.key_id) }}
</div>
</td>

View File

@@ -398,8 +398,9 @@
</Card>
<!-- 请求链路追踪卡片 -->
<div v-if="detail.request_id || detail.id">
<div>
<HorizontalRequestTimeline
v-if="showTimeline && (detail.request_id || detail.id)"
ref="timelineRef"
:request-id="detail.request_id || detail.id"
:override-status-code="detail.status_code"
@@ -584,13 +585,17 @@
</TabsContent>
<TabsContent value="request-body">
<!-- 对话视图 -->
<div
v-if="isRequestBodyLoading"
class="p-4"
>
<Skeleton class="h-32 w-full" />
</div>
<ConversationView
v-if="contentViewMode === 'conversation'"
v-else-if="contentViewMode === 'conversation'"
:render-result="requestRenderResult"
empty-message="无请求体信息"
/>
<!-- JSON 视图 -->
<JsonContent
v-else
:data="currentRequestBody"
@@ -629,13 +634,17 @@
</TabsContent>
<TabsContent value="response-body">
<!-- 对话视图 -->
<div
v-if="isResponseBodyLoading"
class="p-4"
>
<Skeleton class="h-32 w-full" />
</div>
<ConversationView
v-if="contentViewMode === 'conversation'"
v-else-if="contentViewMode === 'conversation'"
:render-result="responseRenderResult"
empty-message="无响应体信息"
/>
<!-- JSON 视图 -->
<JsonContent
v-else
:data="currentResponseBody"
@@ -751,9 +760,15 @@ const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hid
const curlCopying = ref(false)
const curlCopied = ref(false)
const replayDialogOpen = ref(false)
const bodyLoading = ref(false)
const bodiesLoadedForRequestId = ref<string | null>(null)
const showTimeline = ref(false)
const AUTO_REFRESH_INTERVAL_MS = 1000
const TIMELINE_MOUNT_DELAY_MS = 120
let loadDetailRequestId = 0
let bodyLoadRequestId = 0
let loadDetailInFlight = false
let timelineMountTimer: ReturnType<typeof setTimeout> | null = null
// 监听标签页切换
watch(activeTab, (newTab) => {
@@ -765,6 +780,10 @@ watch(activeTab, (newTab) => {
contentViewMode.value = 'json'
}
dataSource.value = getDefaultDataSourceForTab(newTab)
if (['request-body', 'response-body'].includes(newTab)) {
void ensureBodyContentLoaded()
}
})
// 检测暗色模式
@@ -778,6 +797,46 @@ const traceRequestMetadata = computed<Record<string, unknown> | null>(() => {
return meta as Record<string, unknown>
})
function hasBodyContent(flag: boolean | undefined, data: unknown): boolean {
return Boolean(flag) || hasContent(data)
}
const hasRequestBodyAvailable = computed(() => {
return hasBodyContent(detail.value?.has_request_body, detail.value?.request_body)
|| hasBodyContent(detail.value?.has_provider_request_body, detail.value?.provider_request_body)
})
const hasResponseBodyAvailable = computed(() => {
return hasBodyContent(detail.value?.has_response_body, detail.value?.response_body)
|| hasBodyContent(detail.value?.has_client_response_body, detail.value?.client_response_body)
})
const isRequestBodyLoading = computed(() => {
return bodyLoading.value && activeTab.value === 'request-body' && !currentRequestBody.value
})
const isResponseBodyLoading = computed(() => {
return bodyLoading.value && activeTab.value === 'response-body' && !currentResponseBody.value
})
function clearTimelineMountTimer() {
if (timelineMountTimer) {
clearTimeout(timelineMountTimer)
timelineMountTimer = null
}
}
function scheduleTimelineMount() {
clearTimelineMountTimer()
if (typeof window === 'undefined') {
showTimeline.value = true
return
}
timelineMountTimer = window.setTimeout(() => {
showTimeline.value = true
}, TIMELINE_MOUNT_DELAY_MS)
}
// 检测是否有提供商请求头
const hasProviderHeaders = computed(() => {
return !!(detail.value?.provider_request_headers &&
@@ -786,12 +845,13 @@ const hasProviderHeaders = computed(() => {
// 请求体:仅当 provider_request_body 存在时才展示来源切换
const hasProviderRequestBody = computed(() => {
return hasContent(detail.value?.provider_request_body)
return hasBodyContent(detail.value?.has_provider_request_body, detail.value?.provider_request_body)
})
// 响应体:只有客户端侧和 provider 侧都存在时才展示来源切换
const hasProviderResponseBody = computed(() => {
return hasContent(detail.value?.response_body) && hasContent(detail.value?.client_response_body)
return hasBodyContent(detail.value?.has_response_body, detail.value?.response_body)
&& hasBodyContent(detail.value?.has_client_response_body, detail.value?.client_response_body)
})
// 检测是否有两套响应头(客户端侧 + 提供商侧)
@@ -895,6 +955,9 @@ const requestRenderResult = computed<RenderResult>(() => {
if (!body) {
return { blocks: [], isStream: false }
}
if (activeTab.value !== 'request-body' || contentViewMode.value !== 'conversation') {
return { blocks: [], isStream: false }
}
return renderRequest(body, currentResponseBody.value, detail.value?.api_format)
})
@@ -904,6 +967,9 @@ const responseRenderResult = computed<RenderResult>(() => {
if (!body) {
return { blocks: [], isStream: false }
}
if (activeTab.value !== 'response-body' || contentViewMode.value !== 'conversation') {
return { blocks: [], isStream: false }
}
return renderResponse(body, currentRequestBody.value, detail.value?.api_format)
})
@@ -913,14 +979,13 @@ const supportsConversationView = computed(() => {
})
// 当前对话数据是否有效(用于禁用按钮)
// 不依赖带 tab/mode 守卫的 renderResult直接检查 body 数据是否存在
const hasValidConversation = computed(() => {
if (activeTab.value === 'request-body') {
return !requestRenderResult.value.error &&
requestRenderResult.value.blocks.length > 0
return !!currentRequestBody.value
}
if (activeTab.value === 'response-body') {
return !responseRenderResult.value.error &&
responseRenderResult.value.blocks.length > 0
return !!currentResponseBody.value
}
return false
})
@@ -1168,8 +1233,8 @@ function getDefaultDataSourceForTab(tab: string): 'client' | 'provider' {
}
if (tab === 'request-body') {
if (hasContent(detail.value.provider_request_body)) return 'provider'
if (hasContent(detail.value.request_body)) return 'client'
if (hasBodyContent(detail.value.has_provider_request_body, detail.value.provider_request_body)) return 'provider'
if (hasBodyContent(detail.value.has_request_body, detail.value.request_body)) return 'client'
return 'provider'
}
@@ -1180,8 +1245,8 @@ function getDefaultDataSourceForTab(tab: string): 'client' | 'provider' {
}
if (tab === 'response-body') {
if (hasContent(detail.value.client_response_body)) return 'client'
if (hasContent(detail.value.response_body)) return 'provider'
if (hasBodyContent(detail.value.has_client_response_body, detail.value.client_response_body)) return 'client'
if (hasBodyContent(detail.value.has_response_body, detail.value.response_body)) return 'provider'
return 'client'
}
@@ -1213,11 +1278,11 @@ const visibleTabs = computed(() => {
case 'request-headers':
return hasContent(detail.value?.request_headers) || hasContent(detail.value?.provider_request_headers)
case 'request-body':
return hasContent(detail.value?.request_body) || hasContent(detail.value?.provider_request_body)
return hasRequestBodyAvailable.value
case 'response-headers':
return hasContent(detail.value?.response_headers) || hasContent(detail.value?.client_response_headers)
case 'response-body':
return hasContent(detail.value?.response_body) || hasContent(detail.value?.client_response_body)
return hasResponseBodyAvailable.value
case 'metadata':
return hasContent(detail.value?.metadata)
default:
@@ -1237,9 +1302,47 @@ watch(() => props.isOpen, async (isOpen) => {
await loadDetail(props.requestId)
} else if (!isOpen) {
stopAutoRefresh()
showTimeline.value = false
clearTimelineMountTimer()
bodyLoading.value = false
bodiesLoadedForRequestId.value = null
}
})
async function ensureBodyContentLoaded() {
if (!props.requestId || !detail.value) return
const cacheKey = detail.value.request_id || detail.value.id
if (bodiesLoadedForRequestId.value === cacheKey || bodyLoading.value) return
if (!hasRequestBodyAvailable.value && !hasResponseBodyAvailable.value) return
const requestId = ++bodyLoadRequestId
bodyLoading.value = true
try {
const response = await dashboardApi.getRequestDetail(props.requestId, { includeBodies: true })
if (requestId !== bodyLoadRequestId || !detail.value) return
detail.value = {
...detail.value,
request_body: response.request_body,
provider_request_body: response.provider_request_body,
response_body: response.response_body,
client_response_body: response.client_response_body,
has_request_body: response.has_request_body,
has_provider_request_body: response.has_provider_request_body,
has_response_body: response.has_response_body,
has_client_response_body: response.has_client_response_body,
}
bodiesLoadedForRequestId.value = cacheKey
} catch (err) {
if (requestId !== bodyLoadRequestId) return
log.error('Failed to load request bodies:', err)
} finally {
if (requestId === bodyLoadRequestId) {
bodyLoading.value = false
}
}
}
async function loadDetail(id: string, silent = false) {
if (silent && loadDetailInFlight) {
return
@@ -1249,23 +1352,40 @@ async function loadDetail(id: string, silent = false) {
if (!silent) {
loading.value = true
historicalPricing.value = null
showTimeline.value = false
clearTimelineMountTimer()
++bodyLoadRequestId
bodyLoading.value = false
}
error.value = null
try {
const response = await dashboardApi.getRequestDetail(id)
const response = await dashboardApi.getRequestDetail(id, { includeBodies: false })
if (requestId !== loadDetailRequestId) return
detail.value = response
const previousDetail = detail.value
const prevKey = previousDetail?.request_id || previousDetail?.id
const currKey = response.request_id || response.id
const sameRequest = !!prevKey && prevKey === currKey
detail.value = {
...response,
request_body: sameRequest ? previousDetail?.request_body : undefined,
provider_request_body: sameRequest ? previousDetail?.provider_request_body : undefined,
response_body: sameRequest ? previousDetail?.response_body : undefined,
client_response_body: sameRequest ? previousDetail?.client_response_body : undefined,
}
bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null
// 首次加载时选择默认 tab
if (!silent) {
const visibleTabNames = visibleTabs.value.map(t => t.name)
if ((detail.value.request_body || detail.value.provider_request_body) && visibleTabNames.includes('request-body')) {
if (hasRequestBodyAvailable.value && visibleTabNames.includes('request-body')) {
activeTab.value = 'request-body'
} else if ((detail.value.response_body || detail.value.client_response_body) && visibleTabNames.includes('response-body')) {
} else if (hasResponseBodyAvailable.value && visibleTabNames.includes('response-body')) {
activeTab.value = 'response-body'
} else if (visibleTabNames.length > 0) {
activeTab.value = visibleTabNames[0]
}
scheduleTimelineMount()
}
// 根据当前 Tab 的数据可用性自动选择默认数据源
@@ -1396,6 +1516,7 @@ onMounted(() => {
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
stopAutoRefresh()
clearTimelineMountTimer()
loadDetailRequestId += 1
loadDetailInFlight = false
})