mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
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:
@@ -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
|
||||
},
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
51
frontend/src/features/pool/utils/quota-selectors.ts
Normal file
51
frontend/src/features/pool/utils/quota-selectors.ts
Normal 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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user