2025-12-10 20:52:44 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<!-- 请求详情抽屉 -->
|
|
|
|
|
|
<Teleport to="body">
|
|
|
|
|
|
<Transition name="drawer">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="isOpen"
|
|
|
|
|
|
class="fixed inset-0 z-50 flex justify-end"
|
|
|
|
|
|
@click.self="handleClose"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 背景遮罩 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="absolute inset-0 bg-black/30 backdrop-blur-sm"
|
|
|
|
|
|
@click="handleClose"
|
|
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 抽屉内容 -->
|
2026-01-16 01:53:59 +08:00
|
|
|
|
<Card class="relative h-full w-full sm:w-[800px] sm:max-w-[90vw] rounded-none shadow-2xl flex flex-col">
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 固定头部 - 整合基本信息 -->
|
2026-01-16 01:53:59 +08:00
|
|
|
|
<div class="sticky top-0 z-10 bg-background border-b px-3 sm:px-6 py-3 sm:py-4 flex-shrink-0">
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 第一行:标题、模型、状态、操作按钮 -->
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<div class="flex items-center justify-between gap-2 sm:gap-4 mb-3">
|
|
|
|
|
|
<div class="flex min-w-0 flex-1 items-center gap-2 sm:gap-3 flex-wrap">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<h3 class="text-lg font-semibold">
|
|
|
|
|
|
请求详情
|
|
|
|
|
|
</h3>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<div class="flex min-w-0 max-w-[10rem] items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded sm:max-w-none">
|
|
|
|
|
|
<span class="truncate">{{ detail?.model || '-' }}</span>
|
2025-12-18 19:07:20 +08:00
|
|
|
|
<template v-if="detail?.target_model && detail.target_model !== detail.model">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<svg
|
|
|
|
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
|
|
|
|
viewBox="0 0 20 20"
|
|
|
|
|
|
fill="currentColor"
|
|
|
|
|
|
class="w-3 h-3 flex-shrink-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
<path
|
|
|
|
|
|
fill-rule="evenodd"
|
|
|
|
|
|
d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z"
|
|
|
|
|
|
clip-rule="evenodd"
|
|
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</svg>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<span class="truncate">{{ detail.target_model }}</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="detail?.status_code === 200"
|
|
|
|
|
|
variant="success"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ detail.status_code }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
v-else-if="detail"
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ detail.status_code }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<Badge
|
2026-04-23 21:53:50 +08:00
|
|
|
|
v-if="detail && resolveUsageStreamLabelSegments(detail).hasConversion"
|
|
|
|
|
|
:variant="resolveUsageStreamLabelSegments(detail).client === '流式' ? 'secondary' : 'outline'"
|
|
|
|
|
|
:class="resolveUsageStreamLabelSegments(detail).client === '流式'
|
|
|
|
|
|
? 'text-xs inline-flex items-center gap-1'
|
|
|
|
|
|
: 'text-xs inline-flex items-center gap-1 border-border/60 text-muted-foreground'"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2026-04-23 21:53:50 +08:00
|
|
|
|
<span>{{ resolveUsageStreamLabelSegments(detail).client }}</span>
|
|
|
|
|
|
<span class="opacity-60">→</span>
|
|
|
|
|
|
<span>{{ resolveUsageStreamLabelSegments(detail).upstream }}</span>
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
v-else-if="detail"
|
|
|
|
|
|
:variant="isUsageUpstreamStream(detail) ? 'secondary' : 'outline'"
|
|
|
|
|
|
:class="isUsageUpstreamStream(detail)
|
|
|
|
|
|
? 'text-xs'
|
|
|
|
|
|
: 'text-xs border-border/60 text-muted-foreground'"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatUsageStreamLabel(detail) }}
|
2025-12-12 16:15:36 +08:00
|
|
|
|
</Badge>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center gap-1 shrink-0">
|
2026-02-10 03:07:49 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="回放请求"
|
|
|
|
|
|
:disabled="loading"
|
|
|
|
|
|
@click="openReplayDialog"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Play class="w-4 h-4" />
|
|
|
|
|
|
</Button>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
2026-02-07 02:59:02 +08:00
|
|
|
|
:disabled="loading && !autoRefreshing"
|
2026-04-10 20:58:01 +08:00
|
|
|
|
:title="refreshButtonTitle"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
@click="refreshDetail"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<RefreshCw
|
|
|
|
|
|
class="w-4 h-4"
|
2026-02-07 02:59:02 +08:00
|
|
|
|
:class="{ 'animate-spin': loading || autoRefreshing, 'text-primary': autoRefreshing }"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</Button>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="关闭"
|
|
|
|
|
|
@click="handleClose"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<X class="w-4 h-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 第二行:关键元信息 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="detail"
|
2026-05-27 09:20:10 +08:00
|
|
|
|
class="flex items-center flex-wrap gap-x-2 gap-y-1 text-xs text-muted-foreground sm:gap-x-4"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<span class="flex min-w-0 items-center gap-1">
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<span class="font-medium text-foreground">ID:</span>
|
2026-04-07 02:50:19 +08:00
|
|
|
|
<span
|
|
|
|
|
|
class="font-mono"
|
|
|
|
|
|
:title="fullRequestId"
|
|
|
|
|
|
>{{ displayRequestId }}</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</span>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<span class="hidden opacity-40 sm:inline">|</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<span>{{ formatDateTime(detail.created_at) }}</span>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<span class="hidden opacity-40 sm:inline">|</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<span>{{ formatApiFormat(detail.api_format) }}</span>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<span class="hidden opacity-40 sm:inline">|</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<span>用户: {{ detail.user?.username || 'Unknown' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 可滚动内容区域 -->
|
2026-01-16 01:53:59 +08:00
|
|
|
|
<div class="flex-1 min-h-0 overflow-y-auto px-3 sm:px-6 py-3 sm:py-4 scrollbar-stable">
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- Loading State -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="loading"
|
|
|
|
|
|
class="py-8 space-y-4"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Skeleton class="h-8 w-full" />
|
|
|
|
|
|
<Skeleton class="h-32 w-full" />
|
|
|
|
|
|
<Skeleton class="h-64 w-full" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Error State -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Card
|
|
|
|
|
|
v-else-if="error"
|
|
|
|
|
|
class="border-red-200 dark:border-red-800"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div class="p-4">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<p class="text-sm text-red-600 dark:text-red-400">
|
|
|
|
|
|
{{ error }}
|
|
|
|
|
|
</p>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Detail Content -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-else-if="detail"
|
|
|
|
|
|
class="space-y-4"
|
|
|
|
|
|
>
|
2026-05-16 15:42:13 +08:00
|
|
|
|
<!-- 执行失败原因:优先展示本地调度/运行时失败摘要 -->
|
|
|
|
|
|
<Card
|
|
|
|
|
|
v-if="failureNotice"
|
|
|
|
|
|
class="border-red-200 bg-red-50/80 shadow-sm dark:border-red-900/60 dark:bg-red-950/30"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="p-3 sm:p-4 flex gap-3">
|
|
|
|
|
|
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-300">
|
|
|
|
|
|
<AlertTriangle class="h-4 w-4" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="min-w-0 flex-1 space-y-2">
|
|
|
|
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
|
|
|
|
<h4 class="text-sm font-semibold text-red-950 dark:text-red-100">
|
|
|
|
|
|
{{ failureNotice.title }}
|
|
|
|
|
|
</h4>
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="failureNotice.isSchedulingFailure"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="border-red-300 bg-white/60 text-[10px] text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200"
|
|
|
|
|
|
>
|
|
|
|
|
|
调度阶段
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="text-sm leading-6 text-red-900 dark:text-red-100">
|
|
|
|
|
|
{{ failureNotice.message }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="failureNotice.meta.length > 0"
|
|
|
|
|
|
class="flex flex-wrap gap-1.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-for="item in failureNotice.meta"
|
|
|
|
|
|
:key="item"
|
|
|
|
|
|
class="rounded-full border border-red-200 bg-white/70 px-2 py-0.5 text-[11px] font-mono text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-200"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ item }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 费用与性能概览 -->
|
|
|
|
|
|
<Card>
|
2026-01-16 01:53:59 +08:00
|
|
|
|
<div class="p-3 sm:p-4">
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 总费用和响应时间(独立显示) -->
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="grid gap-1.5 mb-4 sm:gap-2"
|
|
|
|
|
|
:class="detailOutputRate != null ? 'grid-cols-3' : 'grid-cols-2'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="min-w-0 rounded-md border border-border/50 bg-muted/20 px-2 py-2 sm:px-3">
|
|
|
|
|
|
<span class="mb-0.5 block truncate text-[10px] text-muted-foreground sm:text-xs">总费用</span>
|
|
|
|
|
|
<span class="block truncate text-sm font-bold text-green-600 dark:text-green-400 sm:text-lg">
|
2025-12-10 20:52:44 +08:00
|
|
|
|
${{ ((typeof detail.cost === 'object' ? detail.cost?.total : detail.cost) || detail.total_cost || 0).toFixed(6) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<div class="min-w-0 rounded-md border border-border/50 bg-muted/20 px-2 py-2 sm:px-3">
|
|
|
|
|
|
<span class="mb-0.5 block truncate text-[10px] text-muted-foreground sm:text-xs">响应时间</span>
|
|
|
|
|
|
<span class="text-sm font-bold sm:text-lg">{{ detail.response_time_ms ? formatResponseTime(detail.response_time_ms).value : 'N/A' }}</span>
|
|
|
|
|
|
<span class="ml-0.5 text-[10px] text-muted-foreground sm:ml-1 sm:text-sm">{{ detail.response_time_ms ? formatResponseTime(detail.response_time_ms).unit : '' }}</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
2026-05-05 17:34:12 +08:00
|
|
|
|
<template v-if="detailOutputRate != null">
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<div class="min-w-0 rounded-md border border-border/50 bg-muted/20 px-2 py-2 sm:px-3">
|
|
|
|
|
|
<span class="mb-0.5 block truncate text-[10px] text-muted-foreground sm:text-xs">输出速度</span>
|
|
|
|
|
|
<span class="text-sm font-bold text-primary sm:text-lg">{{ formatOutputRateValue(detailOutputRate) }}</span>
|
|
|
|
|
|
<span class="ml-0.5 text-[10px] text-muted-foreground sm:ml-1 sm:text-sm">tps</span>
|
2026-05-05 17:34:12 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="hasDetailPerformanceBreakdown"
|
2026-05-27 09:20:10 +08:00
|
|
|
|
class="grid grid-cols-3 gap-1.5 mb-4 text-xs sm:gap-2"
|
2026-05-05 17:34:12 +08:00
|
|
|
|
>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<div class="min-w-0 rounded-md border border-border/50 bg-muted/20 px-2 py-2 sm:px-3">
|
|
|
|
|
|
<div class="mb-1 truncate text-[10px] text-muted-foreground sm:text-xs">
|
2026-05-05 17:34:12 +08:00
|
|
|
|
首字时间
|
|
|
|
|
|
</div>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<div class="truncate font-mono text-xs text-foreground sm:text-sm">
|
2026-05-05 17:34:12 +08:00
|
|
|
|
{{ formatDurationMs(detail.first_byte_time_ms) }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<div class="min-w-0 rounded-md border border-border/50 bg-muted/20 px-2 py-2 sm:px-3">
|
|
|
|
|
|
<div class="mb-1 truncate text-[10px] text-muted-foreground sm:text-xs">
|
2026-05-05 17:34:12 +08:00
|
|
|
|
生成耗时
|
|
|
|
|
|
</div>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<div class="truncate font-mono text-xs text-foreground sm:text-sm">
|
2026-05-05 17:34:12 +08:00
|
|
|
|
{{ formatDurationMs(detailGenerationTimeMs) }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<div class="min-w-0 rounded-md border border-border/50 bg-muted/20 px-2 py-2 sm:px-3">
|
|
|
|
|
|
<div class="mb-1 truncate text-[10px] text-muted-foreground sm:text-xs">
|
2026-05-05 17:34:12 +08:00
|
|
|
|
输出 Tokens
|
|
|
|
|
|
</div>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<div class="truncate font-mono text-xs text-foreground sm:text-sm">
|
2026-05-05 17:34:12 +08:00
|
|
|
|
{{ formatNumber(detailOutputTokens) }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 分隔线 -->
|
|
|
|
|
|
<Separator class="mb-4" />
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<!-- ========== 1. 费用聚合计算 ========== -->
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div class="text-xs text-muted-foreground mb-3 flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
<span class="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground/70">{{ priceSourceLabel }}</span>
|
|
|
|
|
|
<span class="text-foreground">|</span>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<span class="font-mono text-foreground">
|
|
|
|
|
|
总费用 = Token费用 <span class="font-medium">${{ tokenCostTotal.toFixed(6) }}</span>
|
|
|
|
|
|
<template v-if="perRequestCost > 0">
|
|
|
|
|
|
+ 按次费用 <span class="font-medium">${{ perRequestCost.toFixed(6) }}</span>
|
|
|
|
|
|
</template>
|
2026-05-18 02:49:56 +08:00
|
|
|
|
<template v-if="imageOutputCostTotal > 0">
|
|
|
|
|
|
+ 图片输出费用 <span class="font-medium">${{ imageOutputCostTotal.toFixed(6) }}</span>
|
|
|
|
|
|
</template>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<template v-if="videoCostTotal > 0">
|
|
|
|
|
|
+ {{ detail.video_billing?.task_type === 'image' ? '图像' : detail.video_billing?.task_type === 'audio' ? '音频' : '视频' }}费用 <span class="font-medium">${{ videoCostTotal.toFixed(6) }}</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<!-- ========== 2. Token分阶段成本 ========== -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="hasTokenCost"
|
|
|
|
|
|
class="space-y-2 mb-3"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 阶梯标题 -->
|
|
|
|
|
|
<div class="text-xs text-muted-foreground flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
<span class="font-medium text-foreground">Token 计费</span>
|
2026-04-10 17:44:55 +08:00
|
|
|
|
<span class="text-muted-foreground/60">(输入 {{ formatNumber(displayInputTokens) }} + 缓存创建 {{ cacheCreationSummaryText }} + 缓存读取 {{ formatNumber(detail.cache_read_input_tokens || 0) }})</span>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="displayTiers.length > 1"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0 h-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
命中第 {{ currentTierIndex + 1 }} 阶
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 阶梯展示 -->
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-for="(tier, index) in displayTiers"
|
|
|
|
|
|
:key="index"
|
|
|
|
|
|
class="rounded-lg p-3 space-y-2"
|
|
|
|
|
|
:class="index === currentTierIndex
|
|
|
|
|
|
? 'bg-primary/5 border border-primary/30'
|
|
|
|
|
|
: 'bg-muted/20 border border-border/50 opacity-60'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 阶梯标题行 -->
|
2026-01-16 01:53:59 +08:00
|
|
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 text-xs">
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div class="flex items-center gap-2">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<span
|
|
|
|
|
|
class="font-medium"
|
|
|
|
|
|
:class="index === currentTierIndex ? 'text-primary' : 'text-muted-foreground'"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
第 {{ index + 1 }} 阶
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="text-muted-foreground">
|
|
|
|
|
|
{{ getTierRangeText(tier, index, displayTiers) }}
|
|
|
|
|
|
</span>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="index === currentTierIndex"
|
|
|
|
|
|
variant="default"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0 h-4"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
当前
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 单价信息 -->
|
2026-01-16 01:53:59 +08:00
|
|
|
|
<div class="text-muted-foreground flex items-center gap-2 flex-wrap">
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<span>输入 ${{ formatPrice(tier.input_price_per_1m) }}/M</span>
|
|
|
|
|
|
<span>输出 ${{ formatPrice(tier.output_price_per_1m) }}/M</span>
|
2026-04-10 18:50:59 +08:00
|
|
|
|
<span v-if="getTierActiveCacheCreationDisplay(tier)">
|
|
|
|
|
|
{{ getTierActiveCacheCreationDisplay(tier)?.label }}
|
|
|
|
|
|
${{ formatPrice(getTierActiveCacheCreationDisplay(tier)?.price || 0) }}/M
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</span>
|
2026-04-10 18:50:59 +08:00
|
|
|
|
<span v-if="shouldShowCacheReadPrice(tier)">
|
|
|
|
|
|
缓存读取 ${{ formatPrice(getTierActiveCacheReadPrice(tier) ?? 0) }}/M
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 当前阶梯的详细计算 -->
|
|
|
|
|
|
<template v-if="index === currentTierIndex">
|
|
|
|
|
|
<!-- 输入 输出 -->
|
|
|
|
|
|
<div class="flex items-center">
|
|
|
|
|
|
<div class="flex items-center flex-1">
|
|
|
|
|
|
<span class="text-xs text-muted-foreground w-[56px]">输入</span>
|
2026-04-10 17:44:55 +08:00
|
|
|
|
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ displayInputTokens }}</span>
|
2026-04-10 18:50:59 +08:00
|
|
|
|
<span class="text-xs font-mono">${{ effectiveInputCost.toFixed(6) }}</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Separator
|
|
|
|
|
|
orientation="vertical"
|
|
|
|
|
|
class="h-4 mx-4"
|
|
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div class="flex items-center flex-1">
|
|
|
|
|
|
<span class="text-xs text-muted-foreground w-[56px]">输出</span>
|
|
|
|
|
|
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.tokens?.output || detail.output_tokens || 0 }}</span>
|
2026-04-10 18:50:59 +08:00
|
|
|
|
<span class="text-xs font-mono">${{ effectiveOutputCost.toFixed(6) }}</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<!-- 缓存创建 缓存读取 -->
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div class="flex items-center">
|
|
|
|
|
|
<div class="flex items-center flex-1">
|
2026-03-06 13:13:01 +08:00
|
|
|
|
<span class="text-xs text-muted-foreground w-[56px]">{{ cacheCreationSplitRows.length > 0 ? '创建合计' : '缓存创建' }}</span>
|
2026-04-10 18:50:59 +08:00
|
|
|
|
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ totalCacheCreationTokens }}</span>
|
|
|
|
|
|
<span class="text-xs font-mono">${{ effectiveCacheCreationCost.toFixed(6) }}</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Separator
|
|
|
|
|
|
orientation="vertical"
|
|
|
|
|
|
class="h-4 mx-4"
|
|
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div class="flex items-center flex-1">
|
|
|
|
|
|
<span class="text-xs text-muted-foreground w-[56px]">缓存读取</span>
|
|
|
|
|
|
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.cache_read_input_tokens || 0 }}</span>
|
2026-04-10 18:50:59 +08:00
|
|
|
|
<span class="text-xs font-mono">${{ effectiveCacheReadCost.toFixed(6) }}</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-28 11:44:08 +08:00
|
|
|
|
<!-- 缓存创建 5m/1h 细分 -->
|
|
|
|
|
|
<div
|
2026-03-06 13:13:01 +08:00
|
|
|
|
v-if="cacheCreationSplitRows.length > 0"
|
|
|
|
|
|
class="space-y-1 pl-[56px]"
|
2026-02-28 11:44:08 +08:00
|
|
|
|
>
|
2026-03-06 13:13:01 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-for="row in cacheCreationSplitRows"
|
|
|
|
|
|
:key="row.key"
|
|
|
|
|
|
class="flex items-center gap-4 text-xs text-muted-foreground/70"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="w-[72px]">{{ row.label }}</span>
|
|
|
|
|
|
<span class="font-mono text-foreground/90">{{ formatNumber(row.tokens) }}</span>
|
|
|
|
|
|
<span v-if="row.pricePer1M !== null">${{ formatPrice(row.pricePer1M) }}/M</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="row.cost !== null"
|
|
|
|
|
|
class="font-mono"
|
|
|
|
|
|
>${{ row.cost.toFixed(6) }}</span>
|
|
|
|
|
|
</div>
|
2026-02-28 11:44:08 +08:00
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
|
2026-02-07 02:59:02 +08:00
|
|
|
|
<!-- ========== 3. 按次计费 ========== -->
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="perRequestCost > 0 && !detail.video_billing"
|
2026-02-07 02:59:02 +08:00
|
|
|
|
class="space-y-2 mb-3"
|
2026-02-03 18:48:39 +08:00
|
|
|
|
>
|
2026-02-07 02:59:02 +08:00
|
|
|
|
<div class="flex items-center justify-between text-xs">
|
|
|
|
|
|
<span class="font-medium text-foreground">按次计费</span>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
2026-02-07 02:59:02 +08:00
|
|
|
|
<div class="rounded-lg p-3 bg-primary/5 border border-primary/30 space-y-2">
|
|
|
|
|
|
<div
|
2026-04-10 18:50:59 +08:00
|
|
|
|
v-if="effectivePricePerRequest > 0"
|
2026-02-07 02:59:02 +08:00
|
|
|
|
class="flex items-center justify-end text-xs"
|
|
|
|
|
|
>
|
2026-04-10 18:50:59 +08:00
|
|
|
|
<span class="text-muted-foreground">${{ effectivePricePerRequest.toFixed(6) }}/次</span>
|
2026-02-07 02:59:02 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center">
|
|
|
|
|
|
<div class="flex items-center flex-1">
|
|
|
|
|
|
<span class="text-xs text-muted-foreground w-[56px]">请求次数</span>
|
|
|
|
|
|
<span class="text-sm font-semibold font-mono flex-1 text-center">1</span>
|
|
|
|
|
|
<span class="text-xs font-mono font-medium">${{ perRequestCost.toFixed(6) }}</span>
|
|
|
|
|
|
</div>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-18 02:49:56 +08:00
|
|
|
|
<!-- ========== 4. 图片输出计费 ========== -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="hasImageBillingDetail"
|
|
|
|
|
|
class="rounded-lg p-3 space-y-2 bg-primary/5 border border-primary/30 mb-3"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 text-xs">
|
|
|
|
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
<span class="font-medium text-primary">图片输出</span>
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0 h-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ imageOutputBillingLabel }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<span
|
2026-05-18 11:52:01 +08:00
|
|
|
|
v-if="imageOutputPricingDescriptor"
|
2026-05-18 02:49:56 +08:00
|
|
|
|
class="text-muted-foreground font-mono"
|
2026-05-18 11:52:01 +08:00
|
|
|
|
>{{ imageOutputPricingDescriptor }}</span>
|
2026-05-18 02:49:56 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="text-muted-foreground flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="imageOutputPricePerImage !== null"
|
|
|
|
|
|
class="font-mono"
|
|
|
|
|
|
>{{ formatNumber(imageOutputCount) }} 张 × ${{ imageOutputPricePerImage.toFixed(6) }}/张 = ${{ imageOutputCostTotal.toFixed(6) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex items-center">
|
|
|
|
|
|
<div class="flex items-center flex-1">
|
|
|
|
|
|
<span class="text-xs text-muted-foreground w-[56px]">数量</span>
|
|
|
|
|
|
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ formatNumber(imageOutputCount) }}</span>
|
|
|
|
|
|
<span class="text-xs font-mono">${{ imageOutputCostTotal.toFixed(6) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Separator
|
|
|
|
|
|
orientation="vertical"
|
|
|
|
|
|
class="h-4 mx-4"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="flex items-center flex-1">
|
|
|
|
|
|
<span class="text-xs text-muted-foreground w-[56px]">格式</span>
|
|
|
|
|
|
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ imageOutputFormat || '-' }}</span>
|
|
|
|
|
|
<span class="text-xs font-mono text-muted-foreground">{{ imageOutputBillingLabel }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- ========== 5. 视频/图像/音频计费(独立隔离,与Token计费风格一致) ========== -->
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="detail.video_billing"
|
|
|
|
|
|
class="rounded-lg p-3 space-y-2 bg-primary/5 border border-primary/30"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 标题行(与阶梯标题行风格一致) -->
|
|
|
|
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 text-xs">
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<span class="font-medium text-primary">
|
|
|
|
|
|
{{ getTaskTypeLabel(detail.video_billing.task_type) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="detail.video_billing.resolution"
|
|
|
|
|
|
class="text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ detail.video_billing.resolution }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 费用计算公式 -->
|
|
|
|
|
|
<div class="text-muted-foreground flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="detail.video_billing.duration_seconds && detail.video_billing.video_price_per_second"
|
|
|
|
|
|
class="font-mono"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ detail.video_billing.duration_seconds.toFixed(1) }}s × ${{ detail.video_billing.video_price_per_second.toFixed(4) }}/s = ${{ videoCostTotal.toFixed(6) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-else-if="detail.video_billing.video_price_per_second"
|
|
|
|
|
|
class="font-mono"
|
|
|
|
|
|
>
|
|
|
|
|
|
${{ detail.video_billing.video_price_per_second.toFixed(4) }}/秒
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 费用详情(与Token详情行风格一致) -->
|
|
|
|
|
|
<div class="flex items-center">
|
|
|
|
|
|
<div class="flex items-center flex-1">
|
|
|
|
|
|
<span class="text-xs text-muted-foreground w-[56px]">
|
|
|
|
|
|
{{ detail.video_billing.task_type === 'video' ? '时长' : detail.video_billing.task_type === 'audio' ? '时长' : '数量' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="text-sm font-semibold font-mono flex-1 text-center">
|
|
|
|
|
|
{{ detail.video_billing.duration_seconds ? formatDuration(detail.video_billing.duration_seconds) : '1' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="text-xs font-mono">${{ videoCostTotal.toFixed(6) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Separator
|
|
|
|
|
|
orientation="vertical"
|
|
|
|
|
|
class="h-4 mx-4 invisible"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="flex items-center flex-1 invisible">
|
|
|
|
|
|
<span class="text-xs text-muted-foreground w-[56px]">占位</span>
|
|
|
|
|
|
<span class="text-sm font-semibold font-mono flex-1 text-center">0</span>
|
|
|
|
|
|
<span class="text-xs font-mono">$0.000000</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 请求链路追踪卡片 -->
|
2026-03-06 15:40:11 +08:00
|
|
|
|
<div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<HorizontalRequestTimeline
|
2026-05-23 01:22:24 +08:00
|
|
|
|
v-if="showTimeline && traceTimelineRequestId"
|
2026-02-07 02:59:02 +08:00
|
|
|
|
ref="timelineRef"
|
2026-05-23 01:22:24 +08:00
|
|
|
|
:request-id="traceTimelineRequestId"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:override-status-code="detail.status_code"
|
2026-04-24 03:07:11 +08:00
|
|
|
|
:request-status="detail.status"
|
2026-03-03 09:22:20 +08:00
|
|
|
|
:request-api-format="detail.api_format || null"
|
|
|
|
|
|
:request-metadata="traceRequestMetadata"
|
2026-04-25 16:58:17 +08:00
|
|
|
|
@trace-state="handleTraceState"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Tabs 区域 -->
|
|
|
|
|
|
<Card>
|
2026-01-16 01:53:59 +08:00
|
|
|
|
<div class="p-3 sm:p-4">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Tabs
|
|
|
|
|
|
v-model="activeTab"
|
|
|
|
|
|
:default-value="activeTab"
|
|
|
|
|
|
>
|
2026-02-21 13:17:11 +08:00
|
|
|
|
<!-- Tab 行 -->
|
|
|
|
|
|
<div class="flex items-center border-b pb-2 mb-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="tab in visibleTabs"
|
|
|
|
|
|
:key="tab.name"
|
|
|
|
|
|
class="px-2 sm:px-3 py-1.5 text-sm transition-colors border-b-2 -mb-[9px] whitespace-nowrap"
|
|
|
|
|
|
:class="activeTab === tab.name
|
|
|
|
|
|
? 'border-primary text-foreground font-medium'
|
|
|
|
|
|
: 'border-transparent text-muted-foreground hover:text-foreground'"
|
|
|
|
|
|
@click="activeTab = tab.name"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ tab.label }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Tab 内容(统一容器) -->
|
2026-04-25 11:44:01 +08:00
|
|
|
|
<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>
|
2026-02-21 13:17:11 +08:00
|
|
|
|
<!-- 区域1:条件性按钮(cURL、视图切换、对比) -->
|
|
|
|
|
|
<!-- cURL 复制(仅在请求头/请求体 Tab) -->
|
|
|
|
|
|
<template v-if="['request-headers', 'request-body'].includes(activeTab)">
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<button
|
2026-02-21 13:17:11 +08:00
|
|
|
|
:title="curlCopied ? '已复制 cURL' : '复制 cURL'"
|
|
|
|
|
|
class="p-1 rounded transition-colors text-muted-foreground hover:bg-muted"
|
|
|
|
|
|
:disabled="curlCopying"
|
|
|
|
|
|
@click="copyCurlCommand"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
2026-02-21 13:17:11 +08:00
|
|
|
|
<Check
|
|
|
|
|
|
v-if="curlCopied"
|
|
|
|
|
|
class="w-3.5 h-3.5 text-green-500"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Terminal
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="w-3.5 h-3.5"
|
|
|
|
|
|
:class="{ 'animate-pulse': curlCopying }"
|
|
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</template>
|
2026-01-15 18:23:55 +08:00
|
|
|
|
|
2026-02-21 13:17:11 +08:00
|
|
|
|
<!-- 请求体/响应体专用:JSON/对话 视图切换 -->
|
2026-01-15 18:23:55 +08:00
|
|
|
|
<template v-if="supportsConversationView">
|
|
|
|
|
|
<button
|
|
|
|
|
|
:title="contentViewMode === 'json' ? '切换到对话视图' : '切换到 JSON 视图'"
|
2026-02-21 13:17:11 +08:00
|
|
|
|
class="p-1 rounded transition-colors"
|
2026-01-15 18:23:55 +08:00
|
|
|
|
:class="hasValidConversation || contentViewMode === 'conversation'
|
|
|
|
|
|
? 'text-muted-foreground hover:bg-muted'
|
|
|
|
|
|
: 'text-muted-foreground/40 cursor-not-allowed'"
|
|
|
|
|
|
:disabled="!hasValidConversation && contentViewMode === 'json'"
|
|
|
|
|
|
@click="toggleContentView"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Code2
|
|
|
|
|
|
v-if="contentViewMode === 'conversation'"
|
2026-02-21 13:17:11 +08:00
|
|
|
|
class="w-3.5 h-3.5"
|
2026-01-15 18:23:55 +08:00
|
|
|
|
/>
|
|
|
|
|
|
<MessageSquareText
|
|
|
|
|
|
v-else
|
2026-02-21 13:17:11 +08:00
|
|
|
|
class="w-3.5 h-3.5"
|
2026-01-15 18:23:55 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
2026-02-24 15:25:59 +08:00
|
|
|
|
<!-- 请求头/响应头:对比模式 -->
|
|
|
|
|
|
<template v-if="canCompare">
|
2026-02-10 03:07:49 +08:00
|
|
|
|
<button
|
2026-02-21 13:17:11 +08:00
|
|
|
|
title="对比"
|
|
|
|
|
|
class="p-1 rounded transition-colors"
|
|
|
|
|
|
:class="viewMode === 'compare' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'"
|
|
|
|
|
|
@click="viewMode = 'compare'"
|
2026-02-10 03:07:49 +08:00
|
|
|
|
>
|
2026-02-21 13:17:11 +08:00
|
|
|
|
<Columns2 class="w-3.5 h-3.5" />
|
2026-02-10 03:07:49 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
2026-02-21 13:17:11 +08:00
|
|
|
|
<!-- 区域2:客户端/提供商切换 -->
|
|
|
|
|
|
<template v-if="showDataSourceToggle">
|
|
|
|
|
|
<div class="w-px h-3.5 bg-border mx-0.5" />
|
|
|
|
|
|
<button
|
|
|
|
|
|
title="客户端"
|
|
|
|
|
|
class="p-1 rounded transition-colors"
|
|
|
|
|
|
:class="activeDataSource === 'client' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'"
|
|
|
|
|
|
@click="setDataSource('client')"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Monitor class="w-3.5 h-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
title="提供商"
|
|
|
|
|
|
class="p-1 rounded transition-colors"
|
|
|
|
|
|
:class="activeDataSource === 'provider' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'"
|
|
|
|
|
|
@click="setDataSource('provider')"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Server class="w-3.5 h-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 区域3:常驻按钮(展开/收缩、复制) -->
|
2026-03-02 22:44:49 +08:00
|
|
|
|
<div class="w-px h-3.5 bg-border mx-0.5" />
|
2026-04-25 11:44:01 +08:00
|
|
|
|
</template>
|
2026-02-10 03:07:49 +08:00
|
|
|
|
<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"
|
|
|
|
|
|
:header-stats="headerStats"
|
|
|
|
|
|
:is-dark="isDark"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
|
|
<TabsContent value="request-body">
|
2026-03-06 15:40:11 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="isRequestBodyLoading"
|
|
|
|
|
|
class="p-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Skeleton class="h-32 w-full" />
|
|
|
|
|
|
</div>
|
2026-02-10 03:07:49 +08:00
|
|
|
|
<ConversationView
|
2026-03-06 15:40:11 +08:00
|
|
|
|
v-else-if="contentViewMode === 'conversation'"
|
2026-02-10 03:07:49 +08:00
|
|
|
|
:render-result="requestRenderResult"
|
|
|
|
|
|
empty-message="无请求体信息"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<JsonContent
|
|
|
|
|
|
v-else
|
2026-02-21 01:56:28 +08:00
|
|
|
|
:data="currentRequestBody"
|
2026-02-10 03:07:49 +08:00
|
|
|
|
:view-mode="viewMode"
|
|
|
|
|
|
:expand-depth="currentExpandDepth"
|
|
|
|
|
|
:is-dark="isDark"
|
|
|
|
|
|
empty-message="无请求体信息"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
|
|
<TabsContent value="response-headers">
|
2026-02-24 15:25:59 +08:00
|
|
|
|
<RequestHeadersContent
|
|
|
|
|
|
v-if="viewMode === 'compare'"
|
|
|
|
|
|
:detail="detail"
|
|
|
|
|
|
:view-mode="viewMode"
|
|
|
|
|
|
:data-source="dataSource"
|
|
|
|
|
|
:current-header-data="currentResponseHeaderData"
|
|
|
|
|
|
:current-expand-depth="currentExpandDepth"
|
|
|
|
|
|
:has-provider-headers="hasProviderResponseHeaders"
|
|
|
|
|
|
:header-stats="responseHeaderStats"
|
|
|
|
|
|
:is-dark="isDark"
|
|
|
|
|
|
:client-headers="detail.client_response_headers"
|
|
|
|
|
|
:provider-headers="detail.response_headers"
|
|
|
|
|
|
client-label="客户端响应头"
|
|
|
|
|
|
provider-label="提供商响应头"
|
|
|
|
|
|
empty-message="无响应头信息"
|
|
|
|
|
|
/>
|
2026-02-10 03:07:49 +08:00
|
|
|
|
<JsonContent
|
2026-02-24 15:25:59 +08:00
|
|
|
|
v-else
|
2026-02-21 01:56:28 +08:00
|
|
|
|
:data="currentResponseHeaderData"
|
2026-02-10 03:07:49 +08:00
|
|
|
|
:view-mode="viewMode"
|
|
|
|
|
|
:expand-depth="currentExpandDepth"
|
|
|
|
|
|
:is-dark="isDark"
|
|
|
|
|
|
empty-message="无响应头信息"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
|
|
<TabsContent value="response-body">
|
2026-03-06 15:40:11 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="isResponseBodyLoading"
|
|
|
|
|
|
class="p-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Skeleton class="h-32 w-full" />
|
|
|
|
|
|
</div>
|
2026-02-10 03:07:49 +08:00
|
|
|
|
<ConversationView
|
2026-03-06 15:40:11 +08:00
|
|
|
|
v-else-if="contentViewMode === 'conversation'"
|
2026-02-10 03:07:49 +08:00
|
|
|
|
:render-result="responseRenderResult"
|
|
|
|
|
|
empty-message="无响应体信息"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<JsonContent
|
|
|
|
|
|
v-else
|
2026-02-21 01:56:28 +08:00
|
|
|
|
:data="currentResponseBody"
|
2026-02-10 03:07:49 +08:00
|
|
|
|
:view-mode="viewMode"
|
|
|
|
|
|
:expand-depth="currentExpandDepth"
|
|
|
|
|
|
:is-dark="isDark"
|
|
|
|
|
|
empty-message="无响应体信息"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
|
|
<TabsContent value="metadata">
|
|
|
|
|
|
<JsonContent
|
2026-04-14 11:50:52 +08:00
|
|
|
|
:data="metadataPanelData"
|
2026-02-10 03:07:49 +08:00
|
|
|
|
:view-mode="viewMode"
|
|
|
|
|
|
:expand-depth="currentExpandDepth"
|
|
|
|
|
|
:is-dark="isDark"
|
|
|
|
|
|
empty-message="无元数据信息"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TabsContent>
|
2026-04-25 11:44:01 +08:00
|
|
|
|
</JsonContentPanel>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</Tabs>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Transition>
|
|
|
|
|
|
</Teleport>
|
2026-02-10 03:07:49 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 请求回放对话框 -->
|
|
|
|
|
|
<ReplayDialog
|
|
|
|
|
|
:is-open="replayDialogOpen"
|
|
|
|
|
|
:request-id="requestId"
|
|
|
|
|
|
:detail="detail"
|
|
|
|
|
|
@close="replayDialogOpen = false"
|
|
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-03-03 22:04:40 +08:00
|
|
|
|
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import Button from '@/components/ui/button.vue'
|
2025-12-19 17:31:15 +08:00
|
|
|
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
2025-12-28 20:41:52 +08:00
|
|
|
|
import { useClipboard } from '@/composables/useClipboard'
|
2026-05-09 10:46:04 +08:00
|
|
|
|
import { useDarkMode } from '@/composables/useDarkMode'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import Card from '@/components/ui/card.vue'
|
|
|
|
|
|
import Badge from '@/components/ui/badge.vue'
|
|
|
|
|
|
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'
|
2026-05-16 15:42:13 +08:00
|
|
|
|
import { AlertTriangle, Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
|
2026-04-25 16:58:17 +08:00
|
|
|
|
import { dashboardApi, type RequestDetail, type RequestErrorDomain } from '@/api/dashboard'
|
2026-05-09 01:21:26 +08:00
|
|
|
|
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
|
2026-02-19 14:51:08 +08:00
|
|
|
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
2026-05-22 17:11:56 +08:00
|
|
|
|
import { formatCompactNumber, formatShortRequestId, formatTokens } from '@/utils/format'
|
2025-12-12 20:22:03 +08:00
|
|
|
|
import { log } from '@/utils/logger'
|
2026-04-10 17:44:55 +08:00
|
|
|
|
import { getEffectiveInputTokens } from '../token-normalization'
|
2026-05-05 17:34:12 +08:00
|
|
|
|
import {
|
|
|
|
|
|
formatDurationMs,
|
|
|
|
|
|
formatOutputRate,
|
|
|
|
|
|
formatOutputRateValue,
|
|
|
|
|
|
getDisplayOutputRate,
|
|
|
|
|
|
getGenerationTimeMs,
|
|
|
|
|
|
} from '../performance'
|
2026-04-23 21:53:50 +08:00
|
|
|
|
import {
|
|
|
|
|
|
formatUsageStreamLabel,
|
|
|
|
|
|
isUsageUpstreamStream,
|
2026-04-24 03:07:11 +08:00
|
|
|
|
resolveDisplayRequestStatus,
|
2026-04-23 21:53:50 +08:00
|
|
|
|
resolveUsageStreamLabelSegments,
|
|
|
|
|
|
} from '../utils/status'
|
2026-05-16 15:42:13 +08:00
|
|
|
|
import { resolveRequestFailureNotice } from '../utils/errorNotice'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 子组件
|
|
|
|
|
|
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
|
|
|
|
|
|
import JsonContent from './RequestDetailDrawer/JsonContent.vue'
|
2026-04-25 11:44:01 +08:00
|
|
|
|
import JsonContentPanel from './JsonContentPanel.vue'
|
2026-01-15 18:23:55 +08:00
|
|
|
|
import ConversationView from './RequestDetailDrawer/ConversationView.vue'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import HorizontalRequestTimeline from './HorizontalRequestTimeline.vue'
|
2026-02-10 03:07:49 +08:00
|
|
|
|
import ReplayDialog from './ReplayDialog.vue'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-01-16 02:53:47 +08:00
|
|
|
|
// 对话解析器
|
2026-01-15 18:23:55 +08:00
|
|
|
|
import {
|
2026-01-16 02:53:47 +08:00
|
|
|
|
renderRequest,
|
|
|
|
|
|
renderResponse,
|
|
|
|
|
|
type RenderResult,
|
|
|
|
|
|
type RenderBlock,
|
2026-01-16 03:11:41 +08:00
|
|
|
|
} from '../conversation'
|
2026-01-15 18:23:55 +08:00
|
|
|
|
|
2026-05-24 18:48:50 +08:00
|
|
|
|
type RequestStateStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
|
|
|
|
|
|
|
|
|
|
|
const REQUEST_STATE_STATUSES = new Set<RequestStateStatus>([
|
|
|
|
|
|
'pending',
|
|
|
|
|
|
'streaming',
|
|
|
|
|
|
'completed',
|
|
|
|
|
|
'failed',
|
|
|
|
|
|
'cancelled',
|
|
|
|
|
|
])
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
isOpen: boolean
|
|
|
|
|
|
requestId: string | null
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
close: []
|
2026-05-09 01:21:26 +08:00
|
|
|
|
requestState: [state: {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
requestId?: string | null
|
2026-05-24 18:48:50 +08:00
|
|
|
|
status?: RequestStateStatus
|
2026-05-09 01:21:26 +08:00
|
|
|
|
statusCode?: number | null
|
|
|
|
|
|
responseTimeMs?: number | null
|
|
|
|
|
|
imageProgress?: ImageProgress | null
|
|
|
|
|
|
errorMessage?: string | null
|
|
|
|
|
|
}]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const error = ref<string | null>(null)
|
|
|
|
|
|
const detail = ref<RequestDetail | null>(null)
|
2026-02-07 02:59:02 +08:00
|
|
|
|
const timelineRef = ref<InstanceType<typeof HorizontalRequestTimeline> | null>(null)
|
2026-04-25 16:58:17 +08:00
|
|
|
|
const timelineLoaded = ref(false)
|
|
|
|
|
|
const timelineHasTrace = ref(false)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const activeTab = ref('request-body')
|
|
|
|
|
|
const copiedStates = ref<Record<string, boolean>>({})
|
2026-01-15 19:23:01 +08:00
|
|
|
|
const viewMode = ref<'compare' | 'formatted' | 'raw'>('formatted')
|
|
|
|
|
|
const currentExpandDepth = ref(0)
|
2026-01-16 01:38:59 +08:00
|
|
|
|
const dataSource = ref<'client' | 'provider'>('provider')
|
2026-01-15 18:23:55 +08:00
|
|
|
|
const contentViewMode = ref<'json' | 'conversation'>('json')
|
2025-12-28 20:41:52 +08:00
|
|
|
|
const { copyToClipboard } = useClipboard()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const historicalPricing = ref<{
|
|
|
|
|
|
input_price: string
|
|
|
|
|
|
output_price: string
|
|
|
|
|
|
cache_creation_price: string
|
|
|
|
|
|
cache_read_price: string
|
|
|
|
|
|
request_price: string
|
|
|
|
|
|
} | null>(null)
|
2026-03-06 13:13:01 +08:00
|
|
|
|
|
|
|
|
|
|
type CacheTTLPriceEntry = {
|
|
|
|
|
|
ttl_minutes?: number | null
|
|
|
|
|
|
cache_creation_price_per_1m?: number | null
|
|
|
|
|
|
cache_read_price_per_1m?: number | null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type PricingTierLike = {
|
2026-04-10 18:50:59 +08:00
|
|
|
|
up_to?: number | null
|
|
|
|
|
|
input_price_per_1m?: number | null
|
|
|
|
|
|
output_price_per_1m?: number | null
|
2026-03-06 13:13:01 +08:00
|
|
|
|
cache_creation_price_per_1m?: number | null
|
|
|
|
|
|
cache_read_price_per_1m?: number | null
|
|
|
|
|
|
cache_ttl_pricing?: CacheTTLPriceEntry[] | null
|
|
|
|
|
|
}
|
2026-04-10 18:50:59 +08:00
|
|
|
|
|
|
|
|
|
|
type JsonRecord = Record<string, unknown>
|
|
|
|
|
|
|
2026-04-25 16:58:17 +08:00
|
|
|
|
type NormalizedErrorDomain = {
|
|
|
|
|
|
source?: string | null
|
|
|
|
|
|
status_code?: number | null
|
|
|
|
|
|
type?: string | null
|
|
|
|
|
|
message: string
|
|
|
|
|
|
code?: string | number | null
|
|
|
|
|
|
category?: string | null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 18:50:59 +08:00
|
|
|
|
function asRecord(value: unknown): JsonRecord | null {
|
|
|
|
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
|
|
|
|
return value as JsonRecord
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 16:58:17 +08:00
|
|
|
|
function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): NormalizedErrorDomain | null {
|
|
|
|
|
|
if (!domain || typeof domain !== 'object') return null
|
|
|
|
|
|
const message = typeof domain.message === 'string' ? domain.message.trim() : ''
|
|
|
|
|
|
if (!message) return null
|
|
|
|
|
|
return {
|
|
|
|
|
|
source: domain.source ?? null,
|
|
|
|
|
|
status_code: domain.status_code ?? null,
|
|
|
|
|
|
type: domain.type ?? null,
|
|
|
|
|
|
message,
|
|
|
|
|
|
code: domain.code ?? null,
|
|
|
|
|
|
category: domain.category ?? null,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatErrorDomainMeta(domain: NormalizedErrorDomain): string {
|
|
|
|
|
|
const parts: string[] = []
|
|
|
|
|
|
if (domain.status_code != null) parts.push(`HTTP ${domain.status_code}`)
|
|
|
|
|
|
if (domain.type) parts.push(domain.type)
|
|
|
|
|
|
if (domain.source) parts.push(`source=${domain.source}`)
|
|
|
|
|
|
return parts.join(' · ')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 01:21:26 +08:00
|
|
|
|
function mapTraceFinalStatusToRequestStatus(
|
|
|
|
|
|
status?: RequestTrace['final_status'] | null
|
2026-05-24 18:48:50 +08:00
|
|
|
|
): RequestStateStatus | undefined {
|
2026-05-09 01:21:26 +08:00
|
|
|
|
switch (status) {
|
|
|
|
|
|
case 'success':
|
|
|
|
|
|
return 'completed'
|
|
|
|
|
|
case 'failed':
|
|
|
|
|
|
return 'failed'
|
|
|
|
|
|
case 'cancelled':
|
|
|
|
|
|
return 'cancelled'
|
|
|
|
|
|
case 'streaming':
|
|
|
|
|
|
return 'streaming'
|
|
|
|
|
|
case 'pending':
|
|
|
|
|
|
return 'pending'
|
|
|
|
|
|
default:
|
|
|
|
|
|
return undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 18:48:50 +08:00
|
|
|
|
function normalizeRequestStateStatus(status: unknown): RequestStateStatus | undefined {
|
|
|
|
|
|
const normalized = typeof status === 'string' ? status.trim().toLowerCase() : ''
|
|
|
|
|
|
return REQUEST_STATE_STATUSES.has(normalized as RequestStateStatus)
|
|
|
|
|
|
? normalized as RequestStateStatus
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hasRequestFailureSignal(statusCode?: number | null, errorMessage?: string | null): boolean {
|
|
|
|
|
|
return (typeof statusCode === 'number' && statusCode >= 400) ||
|
|
|
|
|
|
(typeof errorMessage === 'string' && errorMessage.trim().length > 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveRequestStateStatus(
|
|
|
|
|
|
status: unknown,
|
|
|
|
|
|
statusCode?: number | null,
|
|
|
|
|
|
errorMessage?: string | null
|
|
|
|
|
|
): RequestStateStatus | undefined {
|
|
|
|
|
|
const normalized = normalizeRequestStateStatus(status)
|
|
|
|
|
|
if ((normalized == null || normalized === 'pending' || normalized === 'streaming') &&
|
|
|
|
|
|
hasRequestFailureSignal(statusCode, errorMessage)) {
|
|
|
|
|
|
return 'failed'
|
|
|
|
|
|
}
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveRequestStateStatusFromDetail(nextDetail: Pick<RequestDetail, 'status' | 'status_code' | 'error_message'>): RequestStateStatus | undefined {
|
|
|
|
|
|
return resolveRequestStateStatus(nextDetail.status, nextDetail.status_code, nextDetail.error_message)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function emitDetailRequestState(nextDetail: RequestDetail) {
|
|
|
|
|
|
const id = props.requestId
|
|
|
|
|
|
if (!id) return
|
|
|
|
|
|
|
|
|
|
|
|
emit('requestState', {
|
|
|
|
|
|
id,
|
|
|
|
|
|
requestId: nextDetail.request_id || nextDetail.id || null,
|
|
|
|
|
|
status: resolveRequestStateStatusFromDetail(nextDetail),
|
|
|
|
|
|
statusCode: nextDetail.status_code ?? undefined,
|
|
|
|
|
|
responseTimeMs: nextDetail.response_time_ms ?? undefined,
|
|
|
|
|
|
errorMessage: nextDetail.error_message ?? undefined,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 01:21:26 +08:00
|
|
|
|
function handleTraceState(state: {
|
|
|
|
|
|
loaded: boolean
|
|
|
|
|
|
hasTrace: boolean
|
|
|
|
|
|
finalStatus?: RequestTrace['final_status'] | null
|
|
|
|
|
|
statusCode?: number | null
|
|
|
|
|
|
latencyMs?: number | null
|
|
|
|
|
|
imageProgress?: ImageProgress | null
|
|
|
|
|
|
errorMessage?: string | null
|
|
|
|
|
|
}) {
|
2026-04-25 16:58:17 +08:00
|
|
|
|
timelineLoaded.value = state.loaded
|
|
|
|
|
|
timelineHasTrace.value = state.hasTrace
|
2026-05-09 01:21:26 +08:00
|
|
|
|
const id = props.requestId
|
|
|
|
|
|
if (!id) return
|
|
|
|
|
|
|
2026-05-24 18:48:50 +08:00
|
|
|
|
const status = resolveRequestStateStatus(
|
|
|
|
|
|
mapTraceFinalStatusToRequestStatus(state.finalStatus),
|
|
|
|
|
|
state.statusCode,
|
|
|
|
|
|
state.errorMessage
|
|
|
|
|
|
)
|
2026-05-09 01:21:26 +08:00
|
|
|
|
const imageFailed = state.imageProgress?.phase === 'failed'
|
|
|
|
|
|
if (!status && !state.imageProgress && state.statusCode == null && state.latencyMs == null) return
|
|
|
|
|
|
|
|
|
|
|
|
emit('requestState', {
|
|
|
|
|
|
id,
|
|
|
|
|
|
requestId: detail.value?.request_id || detail.value?.id || null,
|
|
|
|
|
|
status: imageFailed ? 'failed' : status,
|
|
|
|
|
|
statusCode: state.statusCode ?? undefined,
|
|
|
|
|
|
responseTimeMs: state.latencyMs ?? undefined,
|
|
|
|
|
|
imageProgress: state.imageProgress ?? null,
|
|
|
|
|
|
errorMessage: state.errorMessage ?? undefined,
|
|
|
|
|
|
})
|
2026-04-25 16:58:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 18:50:59 +08:00
|
|
|
|
function toNumber(value: unknown): number | null {
|
|
|
|
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
|
|
|
|
if (typeof value === 'string') {
|
|
|
|
|
|
const parsed = Number(value)
|
|
|
|
|
|
return Number.isFinite(parsed) ? parsed : null
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getNestedValue(record: JsonRecord | null, ...path: string[]): unknown {
|
|
|
|
|
|
let current: unknown = record
|
|
|
|
|
|
for (const key of path) {
|
|
|
|
|
|
const object = asRecord(current)
|
|
|
|
|
|
if (!object) return null
|
|
|
|
|
|
current = object[key]
|
|
|
|
|
|
}
|
|
|
|
|
|
return current
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getNestedNumber(record: JsonRecord | null, ...path: string[]): number | null {
|
|
|
|
|
|
return toNumber(getNestedValue(record, ...path))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 02:49:56 +08:00
|
|
|
|
function getNestedString(record: JsonRecord | null, ...path: string[]): string | null {
|
|
|
|
|
|
const value = getNestedValue(record, ...path)
|
|
|
|
|
|
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 01:22:24 +08:00
|
|
|
|
function getCaseInsensitiveString(record: JsonRecord | null, key: string): string | null {
|
|
|
|
|
|
if (!record) return null
|
|
|
|
|
|
const normalizedKey = key.toLowerCase()
|
|
|
|
|
|
for (const [name, value] of Object.entries(record)) {
|
|
|
|
|
|
if (name.toLowerCase() === normalizedKey && typeof value === 'string' && value.trim()) {
|
|
|
|
|
|
return value.trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 18:50:59 +08:00
|
|
|
|
function normalizeCacheTtlPricing(value: unknown): CacheTTLPriceEntry[] {
|
|
|
|
|
|
if (!Array.isArray(value)) return []
|
|
|
|
|
|
return value
|
|
|
|
|
|
.map((entry) => asRecord(entry))
|
|
|
|
|
|
.filter((entry): entry is JsonRecord => entry !== null)
|
|
|
|
|
|
.map((entry) => ({
|
|
|
|
|
|
ttl_minutes: toNumber(entry.ttl_minutes),
|
|
|
|
|
|
cache_creation_price_per_1m: toNumber(entry.cache_creation_price_per_1m),
|
|
|
|
|
|
cache_read_price_per_1m: toNumber(entry.cache_read_price_per_1m),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
2026-02-07 02:59:02 +08:00
|
|
|
|
const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
|
|
|
|
|
const autoRefreshing = ref(false)
|
2026-03-03 22:04:40 +08:00
|
|
|
|
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
2026-02-10 03:07:49 +08:00
|
|
|
|
const curlCopying = ref(false)
|
|
|
|
|
|
const curlCopied = ref(false)
|
|
|
|
|
|
const replayDialogOpen = ref(false)
|
2026-03-06 15:40:11 +08:00
|
|
|
|
const bodyLoading = ref(false)
|
|
|
|
|
|
const bodiesLoadedForRequestId = ref<string | null>(null)
|
|
|
|
|
|
const showTimeline = ref(false)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
const AUTO_REFRESH_INTERVAL_MS = 1000
|
2026-03-06 15:40:11 +08:00
|
|
|
|
const TIMELINE_MOUNT_DELAY_MS = 120
|
2026-03-03 22:04:40 +08:00
|
|
|
|
let loadDetailRequestId = 0
|
2026-03-06 15:40:11 +08:00
|
|
|
|
let bodyLoadRequestId = 0
|
2026-03-03 22:04:40 +08:00
|
|
|
|
let loadDetailInFlight = false
|
2026-03-06 15:40:11 +08:00
|
|
|
|
let timelineMountTimer: ReturnType<typeof setTimeout> | null = null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-04-07 02:50:19 +08:00
|
|
|
|
const fullRequestId = computed(() => detail.value?.request_id || detail.value?.id || '-')
|
|
|
|
|
|
const displayRequestId = computed(() => formatShortRequestId(fullRequestId.value))
|
2026-04-10 20:58:01 +08:00
|
|
|
|
const refreshButtonTitle = computed(() => {
|
|
|
|
|
|
if (autoRefreshing.value) return '停止自动刷新'
|
|
|
|
|
|
return isRequestCompleted() ? '刷新' : '开启自动刷新'
|
|
|
|
|
|
})
|
2026-04-10 17:44:55 +08:00
|
|
|
|
const displayInputTokens = computed(() => {
|
|
|
|
|
|
if (!detail.value) return 0
|
|
|
|
|
|
return getEffectiveInputTokens({
|
|
|
|
|
|
effective_input_tokens: detail.value.effective_input_tokens,
|
|
|
|
|
|
input_tokens: detail.value.input_tokens ?? detail.value.tokens?.input,
|
|
|
|
|
|
cache_read_input_tokens: detail.value.cache_read_input_tokens,
|
|
|
|
|
|
api_format: detail.value.api_format,
|
|
|
|
|
|
endpoint_api_format: detail.value.endpoint_api_format,
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
2026-04-07 02:50:19 +08:00
|
|
|
|
|
2026-05-05 17:34:12 +08:00
|
|
|
|
const detailOutputTokens = computed(() => {
|
|
|
|
|
|
if (!detail.value) return 0
|
|
|
|
|
|
return detail.value.tokens?.output ?? detail.value.output_tokens ?? 0
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const detailGenerationTimeMs = computed(() => {
|
|
|
|
|
|
if (!detail.value) return null
|
|
|
|
|
|
return getGenerationTimeMs({
|
|
|
|
|
|
response_time_ms: detail.value.response_time_ms,
|
|
|
|
|
|
first_byte_time_ms: detail.value.first_byte_time_ms,
|
|
|
|
|
|
is_stream: detail.value.is_stream,
|
|
|
|
|
|
upstream_is_stream: detail.value.upstream_is_stream,
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const detailOutputRate = computed(() => {
|
|
|
|
|
|
if (!detail.value) return null
|
|
|
|
|
|
return getDisplayOutputRate({
|
|
|
|
|
|
output_tokens: detailOutputTokens.value,
|
|
|
|
|
|
response_time_ms: detail.value.response_time_ms,
|
|
|
|
|
|
first_byte_time_ms: detail.value.first_byte_time_ms,
|
|
|
|
|
|
is_stream: detail.value.is_stream,
|
|
|
|
|
|
upstream_is_stream: detail.value.upstream_is_stream,
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const hasDetailPerformanceBreakdown = computed(() => {
|
|
|
|
|
|
if (!detail.value) return false
|
|
|
|
|
|
return detail.value.first_byte_time_ms != null || detailGenerationTimeMs.value != null || detailOutputTokens.value > 0
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 监听标签页切换
|
|
|
|
|
|
watch(activeTab, (newTab) => {
|
2026-02-24 15:25:59 +08:00
|
|
|
|
if (!['request-headers', 'response-headers'].includes(newTab) && viewMode.value === 'compare') {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
viewMode.value = 'formatted'
|
|
|
|
|
|
}
|
2026-01-15 18:23:55 +08:00
|
|
|
|
// 切换到不支持对话视图的 Tab 时,重置为 JSON 视图
|
|
|
|
|
|
if (!['request-body', 'response-body'].includes(newTab)) {
|
|
|
|
|
|
contentViewMode.value = 'json'
|
|
|
|
|
|
}
|
2026-02-21 21:16:35 +08:00
|
|
|
|
dataSource.value = getDefaultDataSourceForTab(newTab)
|
2026-03-06 15:40:11 +08:00
|
|
|
|
|
|
|
|
|
|
if (['request-body', 'response-body'].includes(newTab)) {
|
|
|
|
|
|
void ensureBodyContentLoaded()
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-09 10:46:04 +08:00
|
|
|
|
const { isDark } = useDarkMode()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-03 09:22:20 +08:00
|
|
|
|
const traceRequestMetadata = computed<Record<string, unknown> | null>(() => {
|
|
|
|
|
|
const meta = detail.value?.metadata
|
|
|
|
|
|
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null
|
|
|
|
|
|
return meta as Record<string, unknown>
|
2026-02-27 13:54:46 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-23 01:22:24 +08:00
|
|
|
|
const traceRecord = computed<Record<string, unknown> | null>(() =>
|
|
|
|
|
|
asRecord(detail.value?.trace ?? null),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const traceTimelineRequestId = computed(() =>
|
|
|
|
|
|
getNestedString(traceRecord.value, 'trace_id')
|
|
|
|
|
|
?? getNestedString(traceRequestMetadata.value, 'trace_id')
|
|
|
|
|
|
?? getCaseInsensitiveString(asRecord(detail.value?.request_headers ?? null), 'x-trace-id')
|
|
|
|
|
|
?? detail.value?.request_id
|
|
|
|
|
|
?? detail.value?.id
|
|
|
|
|
|
?? null,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-14 11:50:52 +08:00
|
|
|
|
const metadataPanelData = computed<Record<string, unknown> | null>(() => {
|
|
|
|
|
|
if (!detail.value) return null
|
|
|
|
|
|
|
|
|
|
|
|
const merged: Record<string, unknown> = {}
|
|
|
|
|
|
if (hasContent(detail.value.metadata)) {
|
|
|
|
|
|
Object.assign(merged, detail.value.metadata || {})
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hasContent(detail.value.routing)) {
|
|
|
|
|
|
merged.routing = detail.value.routing
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hasContent(detail.value.body_capture)) {
|
|
|
|
|
|
merged.body_capture = detail.value.body_capture
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hasContent(detail.value.trace)) {
|
|
|
|
|
|
merged.trace = detail.value.trace
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hasContent(detail.value.settlement)) {
|
|
|
|
|
|
merged.settlement = detail.value.settlement
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Object.keys(merged).length > 0 ? merged : null
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-16 15:42:13 +08:00
|
|
|
|
const failureNotice = computed(() => resolveRequestFailureNotice(detail.value))
|
|
|
|
|
|
|
2026-04-13 14:53:46 +08:00
|
|
|
|
const settlementInfo = computed<JsonRecord | null>(() =>
|
|
|
|
|
|
asRecord(detail.value?.settlement ?? null),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-10 18:50:59 +08:00
|
|
|
|
const billingSnapshot = computed<JsonRecord | null>(() =>
|
2026-04-13 14:53:46 +08:00
|
|
|
|
asRecord(settlementInfo.value?.billing_snapshot)
|
|
|
|
|
|
?? asRecord(traceRequestMetadata.value?.billing_snapshot),
|
2026-04-10 18:50:59 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const billingResolvedVariables = computed<JsonRecord | null>(() =>
|
|
|
|
|
|
asRecord(billingSnapshot.value?.resolved_variables),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-18 02:49:56 +08:00
|
|
|
|
const billingResolvedDimensions = computed<JsonRecord | null>(() =>
|
|
|
|
|
|
asRecord(billingSnapshot.value?.resolved_dimensions),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-10 18:50:59 +08:00
|
|
|
|
const billingCostBreakdown = computed<JsonRecord | null>(() =>
|
|
|
|
|
|
asRecord(billingSnapshot.value?.cost_breakdown),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const billingTierInfo = computed<JsonRecord | null>(() =>
|
|
|
|
|
|
asRecord(billingSnapshot.value?.tier_info),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-06 15:40:11 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 检测是否有提供商请求头
|
|
|
|
|
|
const hasProviderHeaders = computed(() => {
|
|
|
|
|
|
return !!(detail.value?.provider_request_headers &&
|
|
|
|
|
|
Object.keys(detail.value.provider_request_headers).length > 0)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-21 21:16:35 +08:00
|
|
|
|
// 请求体:仅当 provider_request_body 存在时才展示来源切换
|
|
|
|
|
|
const hasProviderRequestBody = computed(() => {
|
2026-03-06 15:40:11 +08:00
|
|
|
|
return hasBodyContent(detail.value?.has_provider_request_body, detail.value?.provider_request_body)
|
2026-02-21 21:16:35 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 响应体:只有客户端侧和 provider 侧都存在时才展示来源切换
|
|
|
|
|
|
const hasProviderResponseBody = computed(() => {
|
2026-03-06 15:40:11 +08:00
|
|
|
|
return hasBodyContent(detail.value?.has_response_body, detail.value?.response_body)
|
|
|
|
|
|
&& hasBodyContent(detail.value?.has_client_response_body, detail.value?.client_response_body)
|
2026-02-21 01:56:28 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 检测是否有两套响应头(客户端侧 + 提供商侧)
|
|
|
|
|
|
const hasProviderResponseHeaders = computed(() => {
|
|
|
|
|
|
return !!(detail.value?.response_headers &&
|
|
|
|
|
|
Object.keys(detail.value.response_headers).length > 0) &&
|
|
|
|
|
|
!!(detail.value?.client_response_headers &&
|
|
|
|
|
|
Object.keys(detail.value.client_response_headers).length > 0)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-24 15:25:59 +08:00
|
|
|
|
// 当前 Tab 是否支持对比模式
|
|
|
|
|
|
const canCompare = computed(() => {
|
|
|
|
|
|
if (activeTab.value === 'request-headers') return hasProviderHeaders.value
|
|
|
|
|
|
if (activeTab.value === 'response-headers') return hasProviderResponseHeaders.value
|
|
|
|
|
|
return false
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 响应头 diff 统计(用于对比模式标题栏)
|
|
|
|
|
|
const responseHeaderStats = computed(() => {
|
|
|
|
|
|
const counts = { added: 0, modified: 0, removed: 0, unchanged: 0 }
|
|
|
|
|
|
if (!detail.value?.client_response_headers || !detail.value?.response_headers) return counts
|
|
|
|
|
|
|
|
|
|
|
|
const clientHeaders = detail.value.client_response_headers as Record<string, unknown>
|
|
|
|
|
|
const providerHeaders = detail.value.response_headers as Record<string, unknown>
|
|
|
|
|
|
const clientKeys = new Set(Object.keys(clientHeaders))
|
|
|
|
|
|
const providerKeys = new Set(Object.keys(providerHeaders))
|
|
|
|
|
|
const allKeys = new Set([...clientKeys, ...providerKeys])
|
|
|
|
|
|
|
|
|
|
|
|
for (const key of allKeys) {
|
|
|
|
|
|
if (clientKeys.has(key) && providerKeys.has(key)) {
|
|
|
|
|
|
counts[clientHeaders[key] === providerHeaders[key] ? 'unchanged' : 'modified']++
|
|
|
|
|
|
} else if (clientKeys.has(key)) {
|
|
|
|
|
|
counts.removed++
|
|
|
|
|
|
} else {
|
|
|
|
|
|
counts.added++
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return counts
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-21 13:17:11 +08:00
|
|
|
|
// 是否显示客户端/提供商切换按钮
|
|
|
|
|
|
const showDataSourceToggle = computed(() => {
|
|
|
|
|
|
if (activeTab.value === 'request-headers') return hasProviderHeaders.value
|
|
|
|
|
|
if (activeTab.value === 'response-headers') return hasProviderResponseHeaders.value
|
2026-02-21 21:16:35 +08:00
|
|
|
|
if (activeTab.value === 'request-body') return hasProviderRequestBody.value
|
|
|
|
|
|
if (activeTab.value === 'response-body') return hasProviderResponseBody.value
|
2026-02-21 13:17:11 +08:00
|
|
|
|
return false
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-24 15:25:59 +08:00
|
|
|
|
// 当前高亮的数据源(对比模式下两个都不高亮)
|
2026-02-21 13:17:11 +08:00
|
|
|
|
const activeDataSource = computed(() => {
|
2026-02-24 15:25:59 +08:00
|
|
|
|
if (['request-headers', 'response-headers'].includes(activeTab.value) && viewMode.value === 'compare') return null
|
2026-02-21 13:17:11 +08:00
|
|
|
|
return dataSource.value
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-24 15:25:59 +08:00
|
|
|
|
// 设置数据源(对比模式下切换数据源需退出对比)
|
2026-02-21 13:17:11 +08:00
|
|
|
|
function setDataSource(source: 'client' | 'provider') {
|
|
|
|
|
|
dataSource.value = source
|
2026-02-24 15:25:59 +08:00
|
|
|
|
if (['request-headers', 'response-headers'].includes(activeTab.value) && viewMode.value === 'compare') {
|
2026-02-21 13:17:11 +08:00
|
|
|
|
viewMode.value = 'formatted'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-21 01:56:28 +08:00
|
|
|
|
// 获取当前数据源的请求体数据
|
|
|
|
|
|
const currentRequestBody = computed(() => {
|
|
|
|
|
|
if (!detail.value) return null
|
|
|
|
|
|
if (dataSource.value === 'provider' && detail.value.provider_request_body) {
|
|
|
|
|
|
return detail.value.provider_request_body
|
|
|
|
|
|
}
|
|
|
|
|
|
return detail.value.request_body
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 获取当前数据源的响应体数据
|
|
|
|
|
|
const currentResponseBody = computed(() => {
|
|
|
|
|
|
if (!detail.value) return null
|
|
|
|
|
|
if (dataSource.value === 'client' && detail.value.client_response_body) {
|
|
|
|
|
|
return detail.value.client_response_body
|
|
|
|
|
|
}
|
|
|
|
|
|
return detail.value.response_body
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-02 18:19:39 +08:00
|
|
|
|
const currentRequestBodyApiFormat = computed(() => {
|
|
|
|
|
|
if (!detail.value) return undefined
|
|
|
|
|
|
if (dataSource.value === 'provider') {
|
|
|
|
|
|
return detail.value.endpoint_api_format || detail.value.api_format
|
|
|
|
|
|
}
|
|
|
|
|
|
return detail.value.api_format || detail.value.endpoint_api_format
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const currentResponseBodyApiFormat = computed(() => {
|
|
|
|
|
|
if (!detail.value) return undefined
|
|
|
|
|
|
if (dataSource.value === 'provider') {
|
|
|
|
|
|
return detail.value.endpoint_api_format || detail.value.api_format
|
|
|
|
|
|
}
|
|
|
|
|
|
return detail.value.api_format || detail.value.endpoint_api_format
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 获取当前数据源的请求头数据
|
|
|
|
|
|
const currentHeaderData = computed(() => {
|
|
|
|
|
|
if (!detail.value) return null
|
2026-02-21 21:16:35 +08:00
|
|
|
|
if (dataSource.value === 'client' && hasContent(detail.value.request_headers)) {
|
|
|
|
|
|
return detail.value.request_headers
|
|
|
|
|
|
}
|
|
|
|
|
|
if (dataSource.value === 'provider' && hasContent(detail.value.provider_request_headers)) {
|
|
|
|
|
|
return detail.value.provider_request_headers
|
|
|
|
|
|
}
|
|
|
|
|
|
// 回退:优先 client,再 provider
|
|
|
|
|
|
if (hasContent(detail.value.request_headers)) {
|
|
|
|
|
|
return detail.value.request_headers
|
|
|
|
|
|
}
|
|
|
|
|
|
return detail.value.provider_request_headers
|
2025-12-10 20:52:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-25 11:44:01 +08:00
|
|
|
|
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'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-16 02:53:47 +08:00
|
|
|
|
// 请求体渲染结果
|
|
|
|
|
|
const requestRenderResult = computed<RenderResult>(() => {
|
2026-02-21 01:56:28 +08:00
|
|
|
|
const body = currentRequestBody.value
|
|
|
|
|
|
if (!body) {
|
2026-01-16 02:53:47 +08:00
|
|
|
|
return { blocks: [], isStream: false }
|
2026-01-15 18:23:55 +08:00
|
|
|
|
}
|
2026-03-06 15:40:11 +08:00
|
|
|
|
if (activeTab.value !== 'request-body' || contentViewMode.value !== 'conversation') {
|
|
|
|
|
|
return { blocks: [], isStream: false }
|
|
|
|
|
|
}
|
2026-05-02 18:19:39 +08:00
|
|
|
|
return renderRequest(body, currentResponseBody.value, currentRequestBodyApiFormat.value)
|
2026-01-15 18:23:55 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-16 02:53:47 +08:00
|
|
|
|
// 响应体渲染结果
|
|
|
|
|
|
const responseRenderResult = computed<RenderResult>(() => {
|
2026-02-21 01:56:28 +08:00
|
|
|
|
const body = currentResponseBody.value
|
|
|
|
|
|
if (!body) {
|
2026-01-16 02:53:47 +08:00
|
|
|
|
return { blocks: [], isStream: false }
|
2026-01-15 18:23:55 +08:00
|
|
|
|
}
|
2026-03-06 15:40:11 +08:00
|
|
|
|
if (activeTab.value !== 'response-body' || contentViewMode.value !== 'conversation') {
|
|
|
|
|
|
return { blocks: [], isStream: false }
|
|
|
|
|
|
}
|
2026-05-02 18:19:39 +08:00
|
|
|
|
return renderResponse(body, currentRequestBody.value, currentResponseBodyApiFormat.value)
|
2026-01-15 18:23:55 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 当前 Tab 是否支持对话视图
|
|
|
|
|
|
const supportsConversationView = computed(() => {
|
|
|
|
|
|
return ['request-body', 'response-body'].includes(activeTab.value)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 当前对话数据是否有效(用于禁用按钮)
|
2026-03-06 15:40:11 +08:00
|
|
|
|
// 不依赖带 tab/mode 守卫的 renderResult,直接检查 body 数据是否存在
|
2026-01-15 18:23:55 +08:00
|
|
|
|
const hasValidConversation = computed(() => {
|
|
|
|
|
|
if (activeTab.value === 'request-body') {
|
2026-03-06 15:40:11 +08:00
|
|
|
|
return !!currentRequestBody.value
|
2026-01-15 18:23:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (activeTab.value === 'response-body') {
|
2026-03-06 15:40:11 +08:00
|
|
|
|
return !!currentResponseBody.value
|
2026-01-15 18:23:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 价格来源标签
|
|
|
|
|
|
// tiered_pricing.source 表示定价来源: 'provider' 或 'global'
|
|
|
|
|
|
const priceSourceLabel = computed(() => {
|
|
|
|
|
|
if (!detail.value) return '历史定价'
|
|
|
|
|
|
|
|
|
|
|
|
const source = detail.value.tiered_pricing?.source
|
|
|
|
|
|
if (source === 'provider') {
|
|
|
|
|
|
return '提供商定价'
|
|
|
|
|
|
} else if (source === 'global') {
|
|
|
|
|
|
return '全局定价'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 没有 tiered_pricing 时,使用历史价格
|
|
|
|
|
|
return '历史定价'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-10 18:50:59 +08:00
|
|
|
|
const cacheCreationInputTokens5m = computed(() => {
|
|
|
|
|
|
if (!detail.value) return 0
|
|
|
|
|
|
return toNumber(detail.value.cache_creation_input_tokens_5m)
|
|
|
|
|
|
?? getNestedNumber(detail.value as unknown as JsonRecord, 'cache_creation_ephemeral_5m_input_tokens')
|
|
|
|
|
|
?? 0
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const cacheCreationInputTokens1h = computed(() => {
|
|
|
|
|
|
if (!detail.value) return 0
|
|
|
|
|
|
return toNumber(detail.value.cache_creation_input_tokens_1h)
|
|
|
|
|
|
?? getNestedNumber(detail.value as unknown as JsonRecord, 'cache_creation_ephemeral_1h_input_tokens')
|
|
|
|
|
|
?? 0
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const totalCacheCreationTokens = computed(() => {
|
|
|
|
|
|
if (!detail.value) return 0
|
|
|
|
|
|
const classified = cacheCreationInputTokens5m.value + cacheCreationInputTokens1h.value
|
|
|
|
|
|
const total = detail.value.cache_creation_input_tokens || 0
|
|
|
|
|
|
if (total === 0 && classified > 0) return classified
|
|
|
|
|
|
return total
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const effectiveInputPricePer1M = computed(() =>
|
|
|
|
|
|
getNestedNumber(billingResolvedVariables.value, 'input_price_per_1m')
|
|
|
|
|
|
?? toNumber(detail.value?.input_price_per_1m)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const effectiveOutputPricePer1M = computed(() =>
|
|
|
|
|
|
getNestedNumber(billingResolvedVariables.value, 'output_price_per_1m')
|
|
|
|
|
|
?? toNumber(detail.value?.output_price_per_1m)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const effectiveCacheCreationPricePer1M = computed(() =>
|
|
|
|
|
|
getNestedNumber(billingResolvedVariables.value, 'cache_creation_price_per_1m')
|
|
|
|
|
|
?? toNumber(detail.value?.cache_creation_price_per_1m)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const effectiveCacheReadPricePer1M = computed(() =>
|
|
|
|
|
|
getNestedNumber(billingResolvedVariables.value, 'cache_read_price_per_1m')
|
|
|
|
|
|
?? toNumber(detail.value?.cache_read_price_per_1m)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const effectivePricePerRequest = computed(() =>
|
|
|
|
|
|
getNestedNumber(billingResolvedVariables.value, 'price_per_request')
|
|
|
|
|
|
?? toNumber(detail.value?.price_per_request)
|
|
|
|
|
|
?? 0,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const effectiveInputCost = computed(() =>
|
|
|
|
|
|
getNestedNumber(billingCostBreakdown.value, 'input_cost')
|
|
|
|
|
|
?? toNumber(detail.value?.input_cost)
|
|
|
|
|
|
?? 0,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const effectiveOutputCost = computed(() =>
|
|
|
|
|
|
getNestedNumber(billingCostBreakdown.value, 'output_cost')
|
|
|
|
|
|
?? toNumber(detail.value?.output_cost)
|
|
|
|
|
|
?? 0,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const effectiveCacheCreationCost = computed(() => {
|
|
|
|
|
|
const snapshotCost = [
|
|
|
|
|
|
getNestedNumber(billingCostBreakdown.value, 'cache_creation_uncategorized_cost'),
|
|
|
|
|
|
getNestedNumber(billingCostBreakdown.value, 'cache_creation_ephemeral_5m_cost'),
|
|
|
|
|
|
getNestedNumber(billingCostBreakdown.value, 'cache_creation_ephemeral_1h_cost'),
|
|
|
|
|
|
].reduce((sum, value) => sum + (value ?? 0), 0)
|
|
|
|
|
|
if (snapshotCost > 0) return snapshotCost
|
|
|
|
|
|
return toNumber(detail.value?.cache_creation_cost) ?? 0
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const effectiveCacheReadCost = computed(() =>
|
|
|
|
|
|
getNestedNumber(billingCostBreakdown.value, 'cache_read_cost')
|
|
|
|
|
|
?? toNumber(detail.value?.cache_read_cost)
|
|
|
|
|
|
?? 0,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const effectiveRequestCost = computed(() => {
|
|
|
|
|
|
const snapshotCost = getNestedNumber(billingCostBreakdown.value, 'request_cost')
|
|
|
|
|
|
if (snapshotCost !== null) return snapshotCost
|
|
|
|
|
|
if (effectivePricePerRequest.value > 0) {
|
|
|
|
|
|
return toNumber(detail.value?.request_cost) ?? 0
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-18 02:49:56 +08:00
|
|
|
|
const effectiveImageOutputCost = computed(() =>
|
|
|
|
|
|
getNestedNumber(billingCostBreakdown.value, 'image_output_cost')
|
|
|
|
|
|
?? toNumber(detail.value?.image_output_cost)
|
|
|
|
|
|
?? 0,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const imageOutputCostTotal = computed(() => effectiveImageOutputCost.value)
|
|
|
|
|
|
|
|
|
|
|
|
const imageOutputPricePerImage = computed(() =>
|
|
|
|
|
|
getNestedNumber(billingResolvedVariables.value, 'image_output_price_per_image'),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const imageOutputCount = computed(() =>
|
|
|
|
|
|
getNestedNumber(billingResolvedDimensions.value, 'image_count')
|
|
|
|
|
|
?? getNestedNumber(traceRequestMetadata.value, 'billing_dimensions', 'image_count')
|
|
|
|
|
|
?? getNestedNumber(traceRequestMetadata.value, 'dimensions', 'image_count')
|
|
|
|
|
|
?? 0,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const imageOutputSize = computed(() =>
|
|
|
|
|
|
getNestedString(billingResolvedDimensions.value, 'image_size')
|
|
|
|
|
|
?? getNestedString(traceRequestMetadata.value, 'billing_dimensions', 'image_size')
|
|
|
|
|
|
?? getNestedString(traceRequestMetadata.value, 'dimensions', 'image_size'),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const imageOutputQuality = computed(() =>
|
|
|
|
|
|
getNestedString(billingResolvedDimensions.value, 'image_quality')
|
|
|
|
|
|
?? getNestedString(traceRequestMetadata.value, 'billing_dimensions', 'image_quality')
|
|
|
|
|
|
?? getNestedString(traceRequestMetadata.value, 'dimensions', 'image_quality'),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const imageOutputFormat = computed(() =>
|
|
|
|
|
|
getNestedString(billingResolvedDimensions.value, 'image_output_format')
|
|
|
|
|
|
?? getNestedString(traceRequestMetadata.value, 'billing_dimensions', 'image_output_format')
|
|
|
|
|
|
?? getNestedString(traceRequestMetadata.value, 'dimensions', 'image_output_format'),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const imagePriceKey = computed(() => {
|
|
|
|
|
|
const snapshotKey = getNestedString(billingResolvedDimensions.value, 'image_price_key')
|
|
|
|
|
|
if (snapshotKey) return snapshotKey
|
|
|
|
|
|
const fallbackKey = [imageOutputSize.value, imageOutputQuality.value].filter(Boolean).join(':')
|
|
|
|
|
|
return fallbackKey || null
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-18 11:52:01 +08:00
|
|
|
|
const imageOutputPriceBucket = computed(() =>
|
|
|
|
|
|
getNestedString(billingResolvedDimensions.value, 'image_output_price_bucket'),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const imageOutputPixels = computed(() =>
|
|
|
|
|
|
getNestedNumber(billingResolvedDimensions.value, 'image_pixels')
|
|
|
|
|
|
?? parseImageSizePixels(imageOutputSize.value),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-18 02:49:56 +08:00
|
|
|
|
const imageOutputPricingMode = computed(() =>
|
|
|
|
|
|
getNestedString(billingResolvedDimensions.value, 'image_output_pricing_mode'),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const imageOutputPricingEnabled = computed(() =>
|
|
|
|
|
|
getNestedValue(billingResolvedDimensions.value, 'image_output_pricing_enabled') === true
|
|
|
|
|
|
|| imageOutputPricingMode.value === 'matrix'
|
2026-05-18 11:52:01 +08:00
|
|
|
|
|| imageOutputPricingMode.value === 'pixel_tiers'
|
2026-05-18 02:49:56 +08:00
|
|
|
|
|| imageOutputPricingMode.value === 'per_image'
|
|
|
|
|
|
|| imageOutputCostTotal.value > 0,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-18 11:52:01 +08:00
|
|
|
|
const imageOutputMatrixEnabled = computed(() => {
|
|
|
|
|
|
if (imageOutputPricingMode.value) return imageOutputPricingMode.value === 'matrix'
|
|
|
|
|
|
return getNestedValue(billingResolvedDimensions.value, 'image_output_matrix_enabled') === true
|
|
|
|
|
|
})
|
2026-05-18 02:49:56 +08:00
|
|
|
|
|
2026-05-18 11:52:01 +08:00
|
|
|
|
const imageOutputRangeEnabled = computed(() => {
|
|
|
|
|
|
if (imageOutputPricingMode.value) return imageOutputPricingMode.value === 'pixel_tiers'
|
|
|
|
|
|
return getNestedValue(billingResolvedDimensions.value, 'image_output_range_enabled') === true
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const imageOutputBillingLabel = computed(() => {
|
|
|
|
|
|
if (imageOutputMatrixEnabled.value) return '矩阵计费'
|
|
|
|
|
|
if (imageOutputRangeEnabled.value) return '像素区间'
|
|
|
|
|
|
return '默认计费'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const imageOutputPricingDescriptor = computed(() => {
|
|
|
|
|
|
if (imageOutputMatrixEnabled.value && imagePriceKey.value) return imagePriceKey.value
|
|
|
|
|
|
|
|
|
|
|
|
const parts: string[] = []
|
|
|
|
|
|
if (imageOutputPriceBucket.value && imageOutputPriceBucket.value !== 'default') {
|
|
|
|
|
|
parts.push(formatImagePriceBucket(imageOutputPriceBucket.value))
|
|
|
|
|
|
}
|
|
|
|
|
|
const sizeQuality = [imageOutputSize.value, imageOutputQuality.value].filter(Boolean).join(' / ')
|
|
|
|
|
|
if (sizeQuality) parts.push(sizeQuality)
|
|
|
|
|
|
if (imageOutputRangeEnabled.value && imageOutputPixels.value !== null) {
|
|
|
|
|
|
parts.push(formatPixels(imageOutputPixels.value))
|
|
|
|
|
|
}
|
|
|
|
|
|
if (parts.length > 0) return parts.join(' · ')
|
|
|
|
|
|
if (imageOutputPriceBucket.value === 'default') return '默认价'
|
|
|
|
|
|
return null
|
|
|
|
|
|
})
|
2026-05-18 02:49:56 +08:00
|
|
|
|
|
|
|
|
|
|
const hasImageBillingDetail = computed(() =>
|
|
|
|
|
|
imageOutputPricingEnabled.value && (imageOutputCount.value > 0 || imageOutputCostTotal.value > 0),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-10 18:50:59 +08:00
|
|
|
|
const fallbackCacheTtlPricing = computed<CacheTTLPriceEntry[]>(() => {
|
|
|
|
|
|
const tierPricing = normalizeCacheTtlPricing(billingTierInfo.value?.cache_ttl_pricing)
|
|
|
|
|
|
if (tierPricing.length > 0) return tierPricing
|
|
|
|
|
|
|
|
|
|
|
|
const rows: CacheTTLPriceEntry[] = []
|
|
|
|
|
|
const cache5mCreationPrice = getNestedNumber(
|
|
|
|
|
|
billingResolvedVariables.value,
|
|
|
|
|
|
'cache_creation_ephemeral_5m_price_per_1m',
|
|
|
|
|
|
)
|
|
|
|
|
|
const cache1hCreationPrice = getNestedNumber(
|
|
|
|
|
|
billingResolvedVariables.value,
|
|
|
|
|
|
'cache_creation_ephemeral_1h_price_per_1m',
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if (cache5mCreationPrice !== null) {
|
|
|
|
|
|
rows.push({
|
|
|
|
|
|
ttl_minutes: 5,
|
|
|
|
|
|
cache_creation_price_per_1m: cache5mCreationPrice,
|
|
|
|
|
|
cache_read_price_per_1m: effectiveCacheReadPricePer1M.value,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cache1hCreationPrice !== null) {
|
|
|
|
|
|
rows.push({
|
|
|
|
|
|
ttl_minutes: 60,
|
|
|
|
|
|
cache_creation_price_per_1m: cache1hCreationPrice,
|
|
|
|
|
|
cache_read_price_per_1m: effectiveCacheReadPricePer1M.value,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return rows
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const activeCacheTtlMinutes = computed(() => {
|
|
|
|
|
|
const snapshotTtl = getNestedNumber(billingSnapshot.value, 'resolved_dimensions', 'cache_ttl_minutes')
|
|
|
|
|
|
if (snapshotTtl !== null && snapshotTtl > 0) {
|
|
|
|
|
|
return Math.trunc(snapshotTtl)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cacheCreationInputTokens1h.value > 0 && cacheCreationInputTokens5m.value === 0) {
|
|
|
|
|
|
return 60
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cacheCreationInputTokens5m.value > 0 && cacheCreationInputTokens1h.value === 0) {
|
|
|
|
|
|
return 5
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 统一的阶梯显示数据
|
|
|
|
|
|
// 如果有 tiered_pricing,使用它;否则用历史价格构建单阶梯
|
|
|
|
|
|
const displayTiers = computed(() => {
|
|
|
|
|
|
if (!detail.value) return []
|
|
|
|
|
|
|
|
|
|
|
|
// 如果有阶梯定价数据,直接使用
|
|
|
|
|
|
if (detail.value.tiered_pricing?.tiers && detail.value.tiered_pricing.tiers.length > 0) {
|
|
|
|
|
|
return detail.value.tiered_pricing.tiers
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 否则用历史价格构建单阶梯(无上限)
|
|
|
|
|
|
return [{
|
|
|
|
|
|
up_to: null,
|
2026-04-10 18:50:59 +08:00
|
|
|
|
input_price_per_1m: effectiveInputPricePer1M.value ?? 0,
|
|
|
|
|
|
output_price_per_1m: effectiveOutputPricePer1M.value ?? 0,
|
|
|
|
|
|
cache_creation_price_per_1m: effectiveCacheCreationPricePer1M.value,
|
|
|
|
|
|
cache_read_price_per_1m: effectiveCacheReadPricePer1M.value,
|
|
|
|
|
|
cache_ttl_pricing: fallbackCacheTtlPricing.value,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 当前命中的阶梯索引
|
|
|
|
|
|
const currentTierIndex = computed(() => {
|
|
|
|
|
|
if (!detail.value) return 0
|
|
|
|
|
|
|
|
|
|
|
|
// 如果有阶梯定价,使用它的 tier_index
|
|
|
|
|
|
if (detail.value.tiered_pricing?.tier_index !== undefined) {
|
|
|
|
|
|
return detail.value.tiered_pricing.tier_index
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 18:50:59 +08:00
|
|
|
|
const snapshotTierIndex = getNestedNumber(billingSnapshot.value, 'tier_index')
|
|
|
|
|
|
if (snapshotTierIndex !== null) {
|
|
|
|
|
|
return Math.max(0, Math.trunc(snapshotTierIndex))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 单阶梯时默认是第0阶
|
|
|
|
|
|
return 0
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-06 13:13:01 +08:00
|
|
|
|
const currentTier = computed<PricingTierLike | null>(() => {
|
|
|
|
|
|
const tier = displayTiers.value[currentTierIndex.value]
|
|
|
|
|
|
if (!tier || typeof tier !== 'object') return null
|
|
|
|
|
|
return tier as PricingTierLike
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const cacheCreationSummaryText = computed(() => {
|
|
|
|
|
|
if (!detail.value) return '0'
|
|
|
|
|
|
|
2026-04-10 18:50:59 +08:00
|
|
|
|
const total = totalCacheCreationTokens.value
|
|
|
|
|
|
const cache5m = cacheCreationInputTokens5m.value
|
|
|
|
|
|
const cache1h = cacheCreationInputTokens1h.value
|
2026-03-06 13:13:01 +08:00
|
|
|
|
|
|
|
|
|
|
if (cache5m <= 0 && cache1h <= 0) {
|
|
|
|
|
|
return formatNumber(total)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const parts: string[] = []
|
|
|
|
|
|
if (cache5m > 0) parts.push(`5min ${formatNumber(cache5m)}`)
|
|
|
|
|
|
if (cache1h > 0) parts.push(`1h ${formatNumber(cache1h)}`)
|
|
|
|
|
|
const remaining = Math.max(0, total - cache5m - cache1h)
|
|
|
|
|
|
if (remaining > 0) parts.push(`其他 ${formatNumber(remaining)}`)
|
|
|
|
|
|
return parts.join(' + ')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const cacheCreationSplitRows = computed(() => {
|
|
|
|
|
|
if (!detail.value) return []
|
|
|
|
|
|
|
|
|
|
|
|
const rows: Array<{
|
|
|
|
|
|
key: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
tokens: number
|
|
|
|
|
|
pricePer1M: number | null
|
|
|
|
|
|
cost: number | null
|
|
|
|
|
|
}> = []
|
|
|
|
|
|
|
2026-04-10 18:50:59 +08:00
|
|
|
|
const cache5m = cacheCreationInputTokens5m.value
|
|
|
|
|
|
const cache1h = cacheCreationInputTokens1h.value
|
2026-03-06 13:13:01 +08:00
|
|
|
|
|
|
|
|
|
|
if (cache5m > 0) {
|
|
|
|
|
|
const pricePer1M = getActiveCachePriceForTTL(5, 'cache_creation_price_per_1m')
|
|
|
|
|
|
rows.push({
|
|
|
|
|
|
key: '5m',
|
|
|
|
|
|
label: '5min 创建',
|
|
|
|
|
|
tokens: cache5m,
|
|
|
|
|
|
pricePer1M,
|
|
|
|
|
|
cost: pricePer1M !== null ? (cache5m * pricePer1M) / 1_000_000 : null,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (cache1h > 0) {
|
|
|
|
|
|
const pricePer1M = getActiveCachePriceForTTL(60, 'cache_creation_price_per_1m')
|
|
|
|
|
|
rows.push({
|
|
|
|
|
|
key: '1h',
|
|
|
|
|
|
label: '1h 创建',
|
|
|
|
|
|
tokens: cache1h,
|
|
|
|
|
|
pricePer1M,
|
|
|
|
|
|
cost: pricePer1M !== null ? (cache1h * pricePer1M) / 1_000_000 : null,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return rows
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 总输入上下文(输入 + 缓存创建 + 缓存读取)
|
2026-02-09 01:05:48 +08:00
|
|
|
|
const _totalInputContext = computed(() => {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (!detail.value) return 0
|
|
|
|
|
|
|
|
|
|
|
|
// 优先使用 tiered_pricing 中的值
|
|
|
|
|
|
if (detail.value.tiered_pricing?.total_input_context !== undefined) {
|
|
|
|
|
|
return detail.value.tiered_pricing.total_input_context
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 否则手动计算
|
|
|
|
|
|
const input = detail.value.tokens?.input || detail.value.input_tokens || 0
|
2026-04-10 18:50:59 +08:00
|
|
|
|
const cacheCreation = totalCacheCreationTokens.value
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const cacheRead = detail.value.cache_read_input_tokens || 0
|
|
|
|
|
|
return input + cacheCreation + cacheRead
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
// Token 费用总计
|
|
|
|
|
|
const tokenCostTotal = computed(() => {
|
|
|
|
|
|
if (!detail.value) return 0
|
2026-04-10 18:50:59 +08:00
|
|
|
|
return effectiveInputCost.value
|
|
|
|
|
|
+ effectiveOutputCost.value
|
|
|
|
|
|
+ effectiveCacheCreationCost.value
|
|
|
|
|
|
+ effectiveCacheReadCost.value
|
2026-02-03 18:48:39 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 按次计费费用(非视频任务时)
|
|
|
|
|
|
const perRequestCost = computed(() => {
|
|
|
|
|
|
if (!detail.value) return 0
|
|
|
|
|
|
// 视频任务的 request_cost 实际上是视频费用,不算按次
|
|
|
|
|
|
if (detail.value.video_billing) return 0
|
2026-04-10 18:50:59 +08:00
|
|
|
|
return effectiveRequestCost.value
|
2026-02-03 18:48:39 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 视频/图像/音频费用
|
|
|
|
|
|
const videoCostTotal = computed(() => {
|
|
|
|
|
|
if (!detail.value?.video_billing) return 0
|
|
|
|
|
|
return detail.value.video_billing.video_cost
|
|
|
|
|
|
|| detail.value.video_billing.cost
|
|
|
|
|
|
|| detail.value.request_cost
|
|
|
|
|
|
|| 0
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 是否有 Token 费用(用于决定是否显示 Token 计费区块)
|
|
|
|
|
|
const hasTokenCost = computed(() => {
|
|
|
|
|
|
if (!detail.value) return false
|
|
|
|
|
|
const inputTokens = detail.value.tokens?.input || detail.value.input_tokens || 0
|
|
|
|
|
|
const outputTokens = detail.value.tokens?.output || detail.value.output_tokens || 0
|
2026-04-10 18:50:59 +08:00
|
|
|
|
const cacheCreation = totalCacheCreationTokens.value
|
2026-02-03 18:48:39 +08:00
|
|
|
|
const cacheRead = detail.value.cache_read_input_tokens || 0
|
|
|
|
|
|
return (inputTokens + outputTokens + cacheCreation + cacheRead) > 0 || tokenCostTotal.value > 0
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const tabs = [
|
|
|
|
|
|
{ name: 'request-headers', label: '请求头' },
|
|
|
|
|
|
{ name: 'request-body', label: '请求体' },
|
|
|
|
|
|
{ name: 'response-headers', label: '响应头' },
|
|
|
|
|
|
{ name: 'response-body', label: '响应体' },
|
|
|
|
|
|
{ name: 'metadata', label: '元数据' },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
// 判断数据是否有实际内容(非空对象/数组)
|
|
|
|
|
|
function hasContent(data: unknown): boolean {
|
|
|
|
|
|
if (data === null || data === undefined) return false
|
|
|
|
|
|
if (typeof data === 'object') {
|
|
|
|
|
|
return Object.keys(data as object).length > 0
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 13:13:01 +08:00
|
|
|
|
function toFiniteNumber(value: unknown): number | null {
|
|
|
|
|
|
const num = Number(value)
|
|
|
|
|
|
return Number.isFinite(num) ? num : null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getTierCachePriceForTTL(
|
|
|
|
|
|
tier: PricingTierLike | null | undefined,
|
|
|
|
|
|
ttlMinutes: number,
|
|
|
|
|
|
priceKey: 'cache_creation_price_per_1m' | 'cache_read_price_per_1m',
|
|
|
|
|
|
): number | null {
|
|
|
|
|
|
const fallback = toFiniteNumber(tier?.[priceKey])
|
|
|
|
|
|
const ttlPricing = Array.isArray(tier?.cache_ttl_pricing)
|
|
|
|
|
|
? tier.cache_ttl_pricing
|
|
|
|
|
|
.filter((entry): entry is CacheTTLPriceEntry => !!entry && typeof entry === 'object')
|
|
|
|
|
|
.sort((a, b) => Number(a.ttl_minutes || 0) - Number(b.ttl_minutes || 0))
|
|
|
|
|
|
: []
|
|
|
|
|
|
|
|
|
|
|
|
if (ttlPricing.length === 0) return fallback
|
|
|
|
|
|
|
2026-04-10 18:50:59 +08:00
|
|
|
|
const matched = ttlPricing.find((entry) => Number(entry.ttl_minutes || 0) === ttlMinutes)
|
2026-03-06 13:13:01 +08:00
|
|
|
|
const price = toFiniteNumber(matched?.[priceKey])
|
|
|
|
|
|
return price ?? fallback
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 18:50:59 +08:00
|
|
|
|
function getTierMatchedCachePricingEntry(
|
|
|
|
|
|
tier: PricingTierLike | null | undefined,
|
|
|
|
|
|
ttlMinutes: number,
|
|
|
|
|
|
): CacheTTLPriceEntry | null {
|
|
|
|
|
|
const ttlPricing = Array.isArray(tier?.cache_ttl_pricing)
|
|
|
|
|
|
? tier.cache_ttl_pricing
|
|
|
|
|
|
.filter((entry): entry is CacheTTLPriceEntry => !!entry && typeof entry === 'object')
|
|
|
|
|
|
.sort((a, b) => Number(a.ttl_minutes || 0) - Number(b.ttl_minutes || 0))
|
|
|
|
|
|
: []
|
|
|
|
|
|
|
|
|
|
|
|
if (ttlPricing.length === 0) return null
|
|
|
|
|
|
return ttlPricing.find((entry) => Number(entry.ttl_minutes || 0) === ttlMinutes) ?? null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 13:13:01 +08:00
|
|
|
|
function hasTierCacheCreationSplitPricing(tier: PricingTierLike | null | undefined): boolean {
|
|
|
|
|
|
const ttlPricing = Array.isArray(tier?.cache_ttl_pricing) ? tier.cache_ttl_pricing : []
|
|
|
|
|
|
return ttlPricing.some((entry) =>
|
|
|
|
|
|
Number(entry?.ttl_minutes || 0) >= 60
|
|
|
|
|
|
&& toFiniteNumber(entry?.cache_creation_price_per_1m) !== null,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 18:50:59 +08:00
|
|
|
|
function formatCacheTtlLabel(ttlMinutes: number | null | undefined): string {
|
|
|
|
|
|
if (!ttlMinutes || ttlMinutes <= 0) return '缓存创建'
|
|
|
|
|
|
if (ttlMinutes >= 60) return '缓存创建(1h)'
|
|
|
|
|
|
if (ttlMinutes <= 5) return '缓存创建(5min)'
|
|
|
|
|
|
return `缓存创建(${ttlMinutes}min)`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getTierActiveCacheCreationDisplay(
|
|
|
|
|
|
tier: PricingTierLike | null | undefined,
|
|
|
|
|
|
): { label: string, price: number } | null {
|
|
|
|
|
|
if (hasTierCacheCreationSplitPricing(tier)) {
|
|
|
|
|
|
const activeTtl = activeCacheTtlMinutes.value
|
|
|
|
|
|
if (activeTtl !== null) {
|
|
|
|
|
|
const matchedEntry = getTierMatchedCachePricingEntry(tier, activeTtl)
|
|
|
|
|
|
const matchedPrice = getTierCachePriceForTTL(tier, activeTtl, 'cache_creation_price_per_1m')
|
|
|
|
|
|
if (matchedEntry && matchedPrice !== null) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: formatCacheTtlLabel(matchedEntry.ttl_minutes),
|
|
|
|
|
|
price: matchedPrice,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const fallbackPrice = toFiniteNumber(tier?.cache_creation_price_per_1m)
|
|
|
|
|
|
if (fallbackPrice !== null) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: formatCacheTtlLabel(activeTtl),
|
|
|
|
|
|
price: fallbackPrice,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const fallbackPrice = toFiniteNumber(tier?.cache_creation_price_per_1m)
|
|
|
|
|
|
if (fallbackPrice === null) return null
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: '缓存创建',
|
|
|
|
|
|
price: fallbackPrice,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 13:13:01 +08:00
|
|
|
|
function getActiveCachePriceForTTL(
|
|
|
|
|
|
ttlMinutes: number,
|
|
|
|
|
|
priceKey: 'cache_creation_price_per_1m' | 'cache_read_price_per_1m',
|
|
|
|
|
|
): number | null {
|
|
|
|
|
|
const tierPrice = getTierCachePriceForTTL(currentTier.value, ttlMinutes, priceKey)
|
|
|
|
|
|
if (tierPrice !== null) return tierPrice
|
|
|
|
|
|
|
|
|
|
|
|
if (priceKey === 'cache_creation_price_per_1m') {
|
|
|
|
|
|
return toFiniteNumber(detail.value?.cache_creation_price_per_1m)
|
|
|
|
|
|
}
|
|
|
|
|
|
return toFiniteNumber(detail.value?.cache_read_price_per_1m)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 18:50:59 +08:00
|
|
|
|
function getTierActiveCacheReadPrice(tier: PricingTierLike | null | undefined): number | null {
|
|
|
|
|
|
const activeTtl = activeCacheTtlMinutes.value
|
|
|
|
|
|
if (activeTtl !== null) {
|
|
|
|
|
|
const matchedPrice = getTierCachePriceForTTL(tier, activeTtl, 'cache_read_price_per_1m')
|
|
|
|
|
|
if (matchedPrice !== null) return matchedPrice
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return toFiniteNumber(tier?.cache_read_price_per_1m)
|
|
|
|
|
|
?? effectiveCacheReadPricePer1M.value
|
|
|
|
|
|
?? null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function shouldShowCacheReadPrice(tier: PricingTierLike | null | undefined): boolean {
|
|
|
|
|
|
return getTierActiveCacheReadPrice(tier) !== null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-21 21:16:35 +08:00
|
|
|
|
function getDefaultDataSourceForTab(tab: string): 'client' | 'provider' {
|
|
|
|
|
|
if (!detail.value) {
|
|
|
|
|
|
if (['request-headers', 'request-body'].includes(tab)) return 'provider'
|
|
|
|
|
|
if (['response-headers', 'response-body'].includes(tab)) return 'client'
|
|
|
|
|
|
return dataSource.value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (tab === 'request-headers') {
|
|
|
|
|
|
if (hasContent(detail.value.provider_request_headers)) return 'provider'
|
|
|
|
|
|
if (hasContent(detail.value.request_headers)) return 'client'
|
|
|
|
|
|
return 'provider'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (tab === 'request-body') {
|
2026-03-06 15:40:11 +08:00
|
|
|
|
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'
|
2026-02-21 21:16:35 +08:00
|
|
|
|
return 'provider'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (tab === 'response-headers') {
|
|
|
|
|
|
if (hasContent(detail.value.client_response_headers)) return 'client'
|
|
|
|
|
|
if (hasContent(detail.value.response_headers)) return 'provider'
|
|
|
|
|
|
return 'client'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (tab === 'response-body') {
|
2026-03-06 15:40:11 +08:00
|
|
|
|
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'
|
2026-02-21 21:16:35 +08:00
|
|
|
|
return 'client'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return dataSource.value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-21 01:56:28 +08:00
|
|
|
|
// 获取当前数据源的响应头数据
|
|
|
|
|
|
const currentResponseHeaderData = computed(() => {
|
2026-01-10 18:43:53 +08:00
|
|
|
|
if (!detail.value) return null
|
2026-02-21 01:56:28 +08:00
|
|
|
|
if (dataSource.value === 'client' && hasContent(detail.value.client_response_headers)) {
|
|
|
|
|
|
return detail.value.client_response_headers
|
|
|
|
|
|
}
|
|
|
|
|
|
if (dataSource.value === 'provider' && hasContent(detail.value.response_headers)) {
|
|
|
|
|
|
return detail.value.response_headers
|
|
|
|
|
|
}
|
|
|
|
|
|
// 回退:优先 client,再 provider
|
2026-01-10 18:43:53 +08:00
|
|
|
|
if (hasContent(detail.value.client_response_headers)) {
|
|
|
|
|
|
return detail.value.client_response_headers
|
|
|
|
|
|
}
|
|
|
|
|
|
return detail.value.response_headers
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 根据实际数据决定显示哪些 Tab
|
|
|
|
|
|
const visibleTabs = computed(() => {
|
|
|
|
|
|
if (!detail.value) return []
|
|
|
|
|
|
|
|
|
|
|
|
return tabs.filter(tab => {
|
|
|
|
|
|
switch (tab.name) {
|
|
|
|
|
|
case 'request-headers':
|
2026-02-22 00:43:41 +08:00
|
|
|
|
return hasContent(detail.value?.request_headers) || hasContent(detail.value?.provider_request_headers)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
case 'request-body':
|
2026-03-06 15:40:11 +08:00
|
|
|
|
return hasRequestBodyAvailable.value
|
2025-12-10 20:52:44 +08:00
|
|
|
|
case 'response-headers':
|
2026-02-22 00:43:41 +08:00
|
|
|
|
return hasContent(detail.value?.response_headers) || hasContent(detail.value?.client_response_headers)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
case 'response-body':
|
2026-03-06 15:40:11 +08:00
|
|
|
|
return hasResponseBodyAvailable.value
|
2025-12-10 20:52:44 +08:00
|
|
|
|
case 'metadata':
|
2026-04-14 11:50:52 +08:00
|
|
|
|
return hasContent(metadataPanelData.value)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
watch(() => props.requestId, async (newId) => {
|
|
|
|
|
|
if (newId && props.isOpen) {
|
|
|
|
|
|
await loadDetail(newId)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
watch(() => props.isOpen, async (isOpen) => {
|
|
|
|
|
|
if (isOpen && props.requestId) {
|
|
|
|
|
|
await loadDetail(props.requestId)
|
2026-02-07 02:59:02 +08:00
|
|
|
|
} else if (!isOpen) {
|
|
|
|
|
|
stopAutoRefresh()
|
2026-03-06 15:40:11 +08:00
|
|
|
|
showTimeline.value = false
|
|
|
|
|
|
clearTimelineMountTimer()
|
|
|
|
|
|
bodyLoading.value = false
|
|
|
|
|
|
bodiesLoadedForRequestId.value = null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-06 15:40:11 +08:00
|
|
|
|
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,
|
2026-04-25 16:58:17 +08:00
|
|
|
|
request_error: response.request_error,
|
|
|
|
|
|
upstream_error: response.upstream_error,
|
|
|
|
|
|
client_error: response.client_error,
|
|
|
|
|
|
failure_summary: response.failure_summary,
|
|
|
|
|
|
errors: response.errors,
|
|
|
|
|
|
error_flow: response.error_flow,
|
2026-05-16 15:42:13 +08:00
|
|
|
|
scheduling_failure: response.scheduling_failure,
|
2026-03-06 15:40:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
bodiesLoadedForRequestId.value = cacheKey
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (requestId !== bodyLoadRequestId) return
|
|
|
|
|
|
log.error('Failed to load request bodies:', err)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (requestId === bodyLoadRequestId) {
|
|
|
|
|
|
bodyLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 02:59:02 +08:00
|
|
|
|
async function loadDetail(id: string, silent = false) {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (silent && loadDetailInFlight) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const requestId = ++loadDetailRequestId
|
|
|
|
|
|
loadDetailInFlight = true
|
2026-02-07 02:59:02 +08:00
|
|
|
|
if (!silent) {
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
historicalPricing.value = null
|
2026-04-25 16:58:17 +08:00
|
|
|
|
timelineLoaded.value = false
|
|
|
|
|
|
timelineHasTrace.value = false
|
2026-03-06 15:40:11 +08:00
|
|
|
|
showTimeline.value = false
|
|
|
|
|
|
clearTimelineMountTimer()
|
|
|
|
|
|
++bodyLoadRequestId
|
|
|
|
|
|
bodyLoading.value = false
|
2026-02-07 02:59:02 +08:00
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
error.value = null
|
|
|
|
|
|
try {
|
2026-04-19 15:17:25 +08:00
|
|
|
|
const response = await dashboardApi.getRequestDetail(id, {
|
|
|
|
|
|
includeBodies: false,
|
|
|
|
|
|
cacheTtlMs: silent ? 0 : 5_000
|
|
|
|
|
|
})
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (requestId !== loadDetailRequestId) return
|
2026-03-06 15:40:11 +08:00
|
|
|
|
|
|
|
|
|
|
const previousDetail = detail.value
|
|
|
|
|
|
const prevKey = previousDetail?.request_id || previousDetail?.id
|
|
|
|
|
|
const currKey = response.request_id || response.id
|
|
|
|
|
|
const sameRequest = !!prevKey && prevKey === currKey
|
2026-05-24 18:48:50 +08:00
|
|
|
|
const nextDetail: RequestDetail = {
|
2026-03-06 15:40:11 +08:00
|
|
|
|
...response,
|
2026-05-24 18:48:50 +08:00
|
|
|
|
status: resolveRequestStateStatusFromDetail(response) ?? response.status,
|
2026-03-06 15:40:11 +08:00
|
|
|
|
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,
|
2026-05-16 15:42:13 +08:00
|
|
|
|
request_error: response.request_error,
|
|
|
|
|
|
upstream_error: response.upstream_error,
|
|
|
|
|
|
client_error: response.client_error,
|
|
|
|
|
|
failure_summary: response.failure_summary,
|
|
|
|
|
|
errors: response.errors,
|
|
|
|
|
|
error_flow: response.error_flow,
|
|
|
|
|
|
scheduling_failure: response.scheduling_failure,
|
2026-03-06 15:40:11 +08:00
|
|
|
|
}
|
2026-05-24 18:48:50 +08:00
|
|
|
|
detail.value = nextDetail
|
2026-03-06 15:40:11 +08:00
|
|
|
|
bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null
|
2026-05-24 18:48:50 +08:00
|
|
|
|
emitDetailRequestState(nextDetail)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-06 18:19:57 +08:00
|
|
|
|
// 首次加载时优先停留在轻量 tab,避免默认触发大 body 加载
|
2026-02-07 02:59:02 +08:00
|
|
|
|
if (!silent) {
|
|
|
|
|
|
const visibleTabNames = visibleTabs.value.map(t => t.name)
|
2026-03-06 18:19:57 +08:00
|
|
|
|
if (visibleTabNames.includes('request-headers')) {
|
|
|
|
|
|
activeTab.value = 'request-headers'
|
|
|
|
|
|
} else if (visibleTabNames.includes('response-headers')) {
|
|
|
|
|
|
activeTab.value = 'response-headers'
|
|
|
|
|
|
} else if (visibleTabNames.includes('metadata')) {
|
|
|
|
|
|
activeTab.value = 'metadata'
|
|
|
|
|
|
} else if (visibleTabNames.includes('request-body')) {
|
2026-02-07 02:59:02 +08:00
|
|
|
|
activeTab.value = 'request-body'
|
2026-03-06 18:19:57 +08:00
|
|
|
|
} else if (visibleTabNames.includes('response-body')) {
|
2026-02-07 02:59:02 +08:00
|
|
|
|
activeTab.value = 'response-body'
|
|
|
|
|
|
} else if (visibleTabNames.length > 0) {
|
|
|
|
|
|
activeTab.value = visibleTabNames[0]
|
|
|
|
|
|
}
|
2026-03-06 15:40:11 +08:00
|
|
|
|
scheduleTimelineMount()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-21 21:16:35 +08:00
|
|
|
|
// 根据当前 Tab 的数据可用性自动选择默认数据源
|
|
|
|
|
|
dataSource.value = getDefaultDataSourceForTab(activeTab.value)
|
2026-02-07 02:59:02 +08:00
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 使用请求记录中保存的历史价格
|
|
|
|
|
|
if (detail.value.input_price_per_1m || detail.value.output_price_per_1m || detail.value.price_per_request) {
|
|
|
|
|
|
historicalPricing.value = {
|
|
|
|
|
|
input_price: detail.value.input_price_per_1m ? detail.value.input_price_per_1m.toFixed(4) : 'N/A',
|
|
|
|
|
|
output_price: detail.value.output_price_per_1m ? detail.value.output_price_per_1m.toFixed(4) : 'N/A',
|
|
|
|
|
|
cache_creation_price: detail.value.cache_creation_price_per_1m ? detail.value.cache_creation_price_per_1m.toFixed(4) : 'N/A',
|
|
|
|
|
|
cache_read_price: detail.value.cache_read_price_per_1m ? detail.value.cache_read_price_per_1m.toFixed(4) : 'N/A',
|
|
|
|
|
|
request_price: detail.value.price_per_request ? detail.value.price_per_request.toFixed(4) : 'N/A'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 02:59:02 +08:00
|
|
|
|
|
|
|
|
|
|
// 静默刷新时同步刷新链路追踪
|
|
|
|
|
|
if (silent) {
|
|
|
|
|
|
timelineRef.value?.refresh()
|
|
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
|
2026-04-10 20:58:01 +08:00
|
|
|
|
// 已完成请求需要停止自动刷新;进行中的请求只在用户手动开启后才保持刷新
|
|
|
|
|
|
if (props.isOpen && isRequestCompleted()) {
|
|
|
|
|
|
stopAutoRefresh()
|
2026-02-22 00:43:41 +08:00
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} catch (err) {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (requestId !== loadDetailRequestId) return
|
2025-12-12 20:22:03 +08:00
|
|
|
|
log.error('Failed to load request detail:', err)
|
2026-02-07 02:59:02 +08:00
|
|
|
|
if (!silent) {
|
|
|
|
|
|
error.value = '加载请求详情失败'
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} finally {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (!silent && requestId === loadDetailRequestId) {
|
2026-02-07 02:59:02 +08:00
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (requestId === loadDetailRequestId) {
|
|
|
|
|
|
loadDetailInFlight = false
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleClose() {
|
2026-02-07 02:59:02 +08:00
|
|
|
|
stopAutoRefresh()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
emit('close')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 02:59:02 +08:00
|
|
|
|
function isRequestCompleted(): boolean {
|
|
|
|
|
|
if (!detail.value?.status) return true
|
2026-04-24 03:07:11 +08:00
|
|
|
|
const displayStatus = resolveDisplayRequestStatus(detail.value)
|
|
|
|
|
|
return displayStatus !== 'pending' && displayStatus !== 'streaming'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 02:59:02 +08:00
|
|
|
|
function stopAutoRefresh() {
|
|
|
|
|
|
if (autoRefreshTimer.value) {
|
|
|
|
|
|
clearInterval(autoRefreshTimer.value)
|
|
|
|
|
|
autoRefreshTimer.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
autoRefreshing.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
function startAutoRefresh() {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (autoRefreshTimer.value) {
|
|
|
|
|
|
autoRefreshing.value = true
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!isPageVisible.value || !props.requestId || !props.isOpen) {
|
|
|
|
|
|
autoRefreshing.value = false
|
2026-02-22 00:43:41 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
autoRefreshing.value = true
|
|
|
|
|
|
autoRefreshTimer.value = setInterval(async () => {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (!isPageVisible.value || !props.requestId || !props.isOpen) {
|
2026-02-22 00:43:41 +08:00
|
|
|
|
stopAutoRefresh()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
await loadDetail(props.requestId, true)
|
|
|
|
|
|
if (isRequestCompleted()) {
|
|
|
|
|
|
stopAutoRefresh()
|
|
|
|
|
|
}
|
|
|
|
|
|
}, AUTO_REFRESH_INTERVAL_MS)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 02:59:02 +08:00
|
|
|
|
async function refreshDetail() {
|
|
|
|
|
|
if (!props.requestId) return
|
|
|
|
|
|
|
|
|
|
|
|
// 已完成:单次静默刷新
|
|
|
|
|
|
if (isRequestCompleted()) {
|
|
|
|
|
|
await loadDetail(props.requestId, true)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 未完成:如果已在自动刷新则停止,否则启动
|
|
|
|
|
|
if (autoRefreshing.value) {
|
|
|
|
|
|
stopAutoRefresh()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
autoRefreshing.value = true
|
|
|
|
|
|
await loadDetail(props.requestId, true)
|
|
|
|
|
|
|
|
|
|
|
|
// 加载后可能已经完成了
|
|
|
|
|
|
if (isRequestCompleted()) {
|
|
|
|
|
|
stopAutoRefresh()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
startAutoRefresh()
|
2026-02-07 02:59:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
function handleVisibilityChange() {
|
|
|
|
|
|
isPageVisible.value = !document.hidden
|
|
|
|
|
|
if (!isPageVisible.value) {
|
|
|
|
|
|
stopAutoRefresh()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-07 02:59:02 +08:00
|
|
|
|
onBeforeUnmount(() => {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
2026-02-07 02:59:02 +08:00
|
|
|
|
stopAutoRefresh()
|
2026-03-06 15:40:11 +08:00
|
|
|
|
clearTimelineMountTimer()
|
2026-03-03 22:04:40 +08:00
|
|
|
|
loadDetailRequestId += 1
|
|
|
|
|
|
loadDetailInFlight = false
|
2026-02-07 02:59:02 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
function formatDateTime(dateStr: string | null | undefined): string {
|
|
|
|
|
|
if (!dateStr) return 'N/A'
|
|
|
|
|
|
const date = new Date(dateStr)
|
|
|
|
|
|
return date.toLocaleString('zh-CN', {
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
|
second: '2-digit'
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
// 格式化视频/音频时长
|
|
|
|
|
|
function formatDuration(seconds: number): string {
|
|
|
|
|
|
if (seconds < 60) {
|
|
|
|
|
|
return `${seconds.toFixed(1)}s`
|
|
|
|
|
|
}
|
|
|
|
|
|
const mins = Math.floor(seconds / 60)
|
|
|
|
|
|
const secs = seconds % 60
|
|
|
|
|
|
if (mins < 60) {
|
|
|
|
|
|
return `${mins}m ${secs.toFixed(0)}s`
|
|
|
|
|
|
}
|
|
|
|
|
|
const hours = Math.floor(mins / 60)
|
|
|
|
|
|
const remainMins = mins % 60
|
|
|
|
|
|
return `${hours}h ${remainMins}m`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取任务类型标签
|
|
|
|
|
|
function getTaskTypeLabel(taskType: string): string {
|
|
|
|
|
|
switch (taskType) {
|
|
|
|
|
|
case 'video':
|
|
|
|
|
|
return '视频生成'
|
|
|
|
|
|
case 'image':
|
|
|
|
|
|
return '图像生成'
|
|
|
|
|
|
case 'audio':
|
|
|
|
|
|
return '音频生成'
|
|
|
|
|
|
default:
|
|
|
|
|
|
return taskType
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
function formatNumber(num: number): string {
|
2026-05-22 17:11:56 +08:00
|
|
|
|
return formatTokens(num)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 11:52:01 +08:00
|
|
|
|
function parseImageSizePixels(size: string | null): number | null {
|
|
|
|
|
|
if (!size) return null
|
|
|
|
|
|
const normalized = size.trim().toLowerCase().replace(/\s+/g, '').replace(/×/g, 'x')
|
|
|
|
|
|
const [widthText, heightText] = normalized.split('x')
|
|
|
|
|
|
const width = Number(widthText)
|
|
|
|
|
|
const height = Number(heightText)
|
|
|
|
|
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null
|
|
|
|
|
|
return Math.trunc(width * height)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatImagePriceBucket(bucket: string): string {
|
|
|
|
|
|
if (bucket === 'default') return '默认价'
|
|
|
|
|
|
if (bucket === 'unbounded') return '无上限'
|
|
|
|
|
|
const match = bucket.match(/^<=([0-9]+)px$/)
|
|
|
|
|
|
if (match) return `<= ${formatPixels(Number(match[1]))}`
|
|
|
|
|
|
return bucket
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatPixels(value: number): string {
|
2026-05-22 17:11:56 +08:00
|
|
|
|
return `${formatCompactNumber(value)} px`
|
2026-05-18 11:52:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 格式化响应时间,自动选择合适的单位
|
|
|
|
|
|
function formatResponseTime(ms: number): { value: string; unit: string } {
|
|
|
|
|
|
if (ms >= 1_000) {
|
|
|
|
|
|
return { value: (ms / 1_000).toFixed(2), unit: 's' }
|
|
|
|
|
|
}
|
|
|
|
|
|
return { value: ms.toString(), unit: 'ms' }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化价格,修复浮点数精度问题
|
|
|
|
|
|
function formatPrice(price: number): string {
|
|
|
|
|
|
// 处理浮点数精度问题,最多保留4位小数,去掉尾部的0
|
|
|
|
|
|
const fixed = price.toFixed(4)
|
|
|
|
|
|
return parseFloat(fixed).toString()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取阶梯范围文本
|
|
|
|
|
|
function getTierRangeText(tier: { up_to?: number | null }, index: number, tiers: Array<{ up_to?: number | null }>): string {
|
|
|
|
|
|
const prevTier = index > 0 ? tiers[index - 1] : null
|
|
|
|
|
|
const start = prevTier?.up_to ? prevTier.up_to + 1 : 0
|
|
|
|
|
|
|
|
|
|
|
|
if (tier.up_to) {
|
|
|
|
|
|
if (start === 0) {
|
|
|
|
|
|
return `0 ~ ${formatNumber(tier.up_to)} tokens`
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${formatNumber(start)} ~ ${formatNumber(tier.up_to)} tokens`
|
|
|
|
|
|
}
|
|
|
|
|
|
// 无上限的情况
|
|
|
|
|
|
return `> ${formatNumber(start)} tokens`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 02:53:47 +08:00
|
|
|
|
/** 将 RenderResult 格式化为可复制的文本 */
|
|
|
|
|
|
function formatRenderResultAsText(result: RenderResult): string {
|
|
|
|
|
|
if (result.error) {
|
|
|
|
|
|
return `[Error] ${result.error}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const parts: string[] = []
|
|
|
|
|
|
|
|
|
|
|
|
for (const block of result.blocks) {
|
|
|
|
|
|
const text = formatBlockAsText(block)
|
|
|
|
|
|
if (text) {
|
|
|
|
|
|
parts.push(text)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return parts.join('\n\n---\n\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 将单个 RenderBlock 格式化为文本 */
|
|
|
|
|
|
function formatBlockAsText(block: RenderBlock): string {
|
|
|
|
|
|
switch (block.type) {
|
|
|
|
|
|
case 'text':
|
|
|
|
|
|
return block.content
|
|
|
|
|
|
case 'code':
|
|
|
|
|
|
return block.language
|
|
|
|
|
|
? `\`\`\`${block.language}\n${block.code}\n\`\`\``
|
|
|
|
|
|
: `\`\`\`\n${block.code}\n\`\`\``
|
|
|
|
|
|
case 'collapsible':
|
|
|
|
|
|
return `[${block.title}]\n${block.content.map(formatBlockAsText).filter(Boolean).join('\n')}`
|
|
|
|
|
|
case 'error':
|
|
|
|
|
|
return `[Error${block.code ? `: ${block.code}` : ''}] ${block.message}`
|
|
|
|
|
|
case 'image':
|
|
|
|
|
|
return `[Image: ${block.mimeType || block.alt || 'unknown'}]`
|
|
|
|
|
|
case 'tool_use':
|
|
|
|
|
|
return `[Tool: ${block.toolName}]\n${block.input}`
|
|
|
|
|
|
case 'tool_result':
|
|
|
|
|
|
return `[Tool Result${block.isError ? ' (Error)' : ''}]\n${block.content}`
|
|
|
|
|
|
case 'message': {
|
|
|
|
|
|
const roleLabel = block.roleLabel || block.role
|
|
|
|
|
|
const contentText = block.content.map(formatBlockAsText).filter(Boolean).join('\n\n')
|
|
|
|
|
|
return `[${roleLabel}]\n${contentText}`
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'container':
|
|
|
|
|
|
return block.children.map(formatBlockAsText).filter(Boolean).join('\n')
|
|
|
|
|
|
case 'label':
|
|
|
|
|
|
return `${block.label}: ${block.value}`
|
|
|
|
|
|
case 'divider':
|
|
|
|
|
|
return '---'
|
|
|
|
|
|
case 'badge':
|
|
|
|
|
|
return ''
|
|
|
|
|
|
default:
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 18:23:55 +08:00
|
|
|
|
// 复制内容(支持 JSON 和对话两种模式)
|
|
|
|
|
|
function copyContent(tabName: string) {
|
|
|
|
|
|
if (!detail.value) return
|
|
|
|
|
|
if (viewMode.value === 'compare') return
|
|
|
|
|
|
|
|
|
|
|
|
let textToCopy = ''
|
|
|
|
|
|
|
|
|
|
|
|
// 对话视图模式:复制格式化的对话文本
|
|
|
|
|
|
if (contentViewMode.value === 'conversation') {
|
|
|
|
|
|
if (tabName === 'request-body') {
|
2026-01-16 02:53:47 +08:00
|
|
|
|
textToCopy = formatRenderResultAsText(requestRenderResult.value)
|
2026-01-15 18:23:55 +08:00
|
|
|
|
} else if (tabName === 'response-body') {
|
2026-01-16 02:53:47 +08:00
|
|
|
|
textToCopy = formatRenderResultAsText(responseRenderResult.value)
|
2026-01-15 18:23:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// JSON 视图模式:复制原始 JSON
|
2026-02-22 00:43:41 +08:00
|
|
|
|
let data: unknown = null
|
2026-01-15 18:23:55 +08:00
|
|
|
|
switch (tabName) {
|
|
|
|
|
|
case 'request-headers':
|
|
|
|
|
|
data = dataSource.value === 'provider'
|
|
|
|
|
|
? detail.value.provider_request_headers
|
|
|
|
|
|
: detail.value.request_headers
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'request-body':
|
2026-02-21 01:56:28 +08:00
|
|
|
|
data = currentRequestBody.value
|
2026-01-15 18:23:55 +08:00
|
|
|
|
break
|
|
|
|
|
|
case 'response-headers':
|
2026-02-21 01:56:28 +08:00
|
|
|
|
data = currentResponseHeaderData.value
|
2026-01-15 18:23:55 +08:00
|
|
|
|
break
|
|
|
|
|
|
case 'response-body':
|
2026-02-21 01:56:28 +08:00
|
|
|
|
data = currentResponseBody.value
|
2026-01-15 18:23:55 +08:00
|
|
|
|
break
|
|
|
|
|
|
case 'metadata':
|
2026-04-14 11:50:52 +08:00
|
|
|
|
data = metadataPanelData.value
|
2026-01-15 18:23:55 +08:00
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
if (data) {
|
|
|
|
|
|
textToCopy = JSON.stringify(data, null, 2)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (textToCopy) {
|
|
|
|
|
|
copyToClipboard(textToCopy, false)
|
|
|
|
|
|
copiedStates.value[tabName] = true
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
copiedStates.value[tabName] = false
|
|
|
|
|
|
}, 2000)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 切换内容视图模式
|
|
|
|
|
|
function toggleContentView() {
|
|
|
|
|
|
if (contentViewMode.value === 'json') {
|
|
|
|
|
|
if (hasValidConversation.value) {
|
|
|
|
|
|
contentViewMode.value = 'conversation'
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
contentViewMode.value = 'json'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 03:07:49 +08:00
|
|
|
|
// 复制 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 请求头合并对比逻辑
|
|
|
|
|
|
interface HeaderEntry {
|
|
|
|
|
|
key: string
|
|
|
|
|
|
status: 'added' | 'modified' | 'removed' | 'unchanged'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
originalValue?: unknown
|
|
|
|
|
|
newValue?: unknown
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const mergedHeaderEntries = computed(() => {
|
|
|
|
|
|
if (!detail.value?.request_headers && !detail.value?.provider_request_headers) {
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const clientHeaders = detail.value?.request_headers || {}
|
|
|
|
|
|
const providerHeaders = detail.value?.provider_request_headers || {}
|
|
|
|
|
|
|
|
|
|
|
|
const clientKeys = new Set(Object.keys(clientHeaders))
|
|
|
|
|
|
const providerKeys = new Set(Object.keys(providerHeaders))
|
|
|
|
|
|
const allKeys = new Set([...clientKeys, ...providerKeys])
|
|
|
|
|
|
|
|
|
|
|
|
const entries: HeaderEntry[] = []
|
|
|
|
|
|
|
|
|
|
|
|
for (const key of Array.from(allKeys).sort()) {
|
|
|
|
|
|
const entry: HeaderEntry = { key, status: 'unchanged' }
|
|
|
|
|
|
|
|
|
|
|
|
if (clientKeys.has(key) && providerKeys.has(key)) {
|
|
|
|
|
|
if (clientHeaders[key] !== providerHeaders[key]) {
|
|
|
|
|
|
entry.status = 'modified'
|
|
|
|
|
|
entry.originalValue = clientHeaders[key]
|
|
|
|
|
|
entry.newValue = providerHeaders[key]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
entry.status = 'unchanged'
|
|
|
|
|
|
entry.originalValue = clientHeaders[key]
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (clientKeys.has(key)) {
|
|
|
|
|
|
entry.status = 'removed'
|
|
|
|
|
|
entry.originalValue = clientHeaders[key]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
entry.status = 'added'
|
|
|
|
|
|
entry.newValue = providerHeaders[key]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
entries.push(entry)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return entries
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const headerStats = computed(() => {
|
|
|
|
|
|
const counts = {
|
|
|
|
|
|
added: 0,
|
|
|
|
|
|
modified: 0,
|
|
|
|
|
|
removed: 0,
|
|
|
|
|
|
unchanged: 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const entry of mergedHeaderEntries.value) {
|
|
|
|
|
|
counts[entry.status]++
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return counts
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-19 17:31:15 +08:00
|
|
|
|
// 添加 ESC 键监听
|
|
|
|
|
|
useEscapeKey(() => {
|
|
|
|
|
|
if (props.isOpen) {
|
|
|
|
|
|
handleClose()
|
|
|
|
|
|
}
|
|
|
|
|
|
}, {
|
|
|
|
|
|
disableOnInput: true,
|
|
|
|
|
|
once: false
|
|
|
|
|
|
})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
/* 抽屉过渡动画 */
|
|
|
|
|
|
.drawer-enter-active,
|
|
|
|
|
|
.drawer-leave-active {
|
|
|
|
|
|
transition: opacity 0.3s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drawer-enter-active .relative,
|
|
|
|
|
|
.drawer-leave-active .relative {
|
|
|
|
|
|
transition: transform 0.3s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drawer-enter-from,
|
|
|
|
|
|
.drawer-leave-to {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drawer-enter-from .relative {
|
|
|
|
|
|
transform: translateX(100%);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drawer-leave-to .relative {
|
|
|
|
|
|
transform: translateX(100%);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drawer-enter-to .relative,
|
|
|
|
|
|
.drawer-leave-from .relative {
|
|
|
|
|
|
transform: translateX(0);
|
|
|
|
|
|
}
|
2026-02-10 03:07:49 +08:00
|
|
|
|
|
|
|
|
|
|
/* 内容区融合:子组件的 Card 不再需要自己的边框和圆角,与表头栏融为一体 */
|
|
|
|
|
|
.content-block :deep(.rounded-2xl) {
|
|
|
|
|
|
border: none !important;
|
|
|
|
|
|
border-radius: 0 !important;
|
|
|
|
|
|
box-shadow: none !important;
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</style>
|
|
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
|
/* 滚动条始终预留空间,保持宽度稳定 */
|
|
|
|
|
|
.scrollbar-stable {
|
|
|
|
|
|
scrollbar-gutter: stable;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Webkit 浏览器滚动条样式 */
|
|
|
|
|
|
.scrollbar-stable::-webkit-scrollbar {
|
|
|
|
|
|
width: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.scrollbar-stable::-webkit-scrollbar-track {
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.scrollbar-stable::-webkit-scrollbar-thumb {
|
|
|
|
|
|
background-color: rgba(128, 128, 128, 0.5);
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.scrollbar-stable::-webkit-scrollbar-thumb:hover {
|
|
|
|
|
|
background-color: rgba(128, 128, 128, 0.7);
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|