mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-08 02:32:27 +08:00
Initial commit
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<Card class="p-4 !overflow-visible">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-sm font-semibold">{{ title }}</p>
|
||||
<div v-if="hasData" class="flex items-center gap-1 text-[11px] text-muted-foreground flex-shrink-0">
|
||||
<span class="flex-shrink-0">少</span>
|
||||
<div
|
||||
v-for="(level, index) in legendLevels"
|
||||
:key="index"
|
||||
class="w-3 h-3 rounded-[3px] flex-shrink-0"
|
||||
:style="{ backgroundColor: `rgba(var(--color-primary-rgb), ${level})` }"
|
||||
/>
|
||||
<span class="flex-shrink-0">多</span>
|
||||
</div>
|
||||
</div>
|
||||
<ActivityHeatmap
|
||||
v-if="hasData"
|
||||
:data="data"
|
||||
:show-header="false"
|
||||
/>
|
||||
<div v-else class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground">
|
||||
暂无活跃数据
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import ActivityHeatmap from '@/components/stats/ActivityHeatmap.vue'
|
||||
import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
|
||||
|
||||
const props = defineProps<{
|
||||
data: ActivityHeatmapData | null
|
||||
title: string
|
||||
}>()
|
||||
|
||||
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||
|
||||
const hasData = computed(() =>
|
||||
props.data && props.data.days && props.data.days.length > 0
|
||||
)
|
||||
</script>
|
||||
1322
frontend/src/features/usage/components/HorizontalRequestTimeline.vue
Normal file
1322
frontend/src/features/usage/components/HorizontalRequestTimeline.vue
Normal file
File diff suppressed because it is too large
Load Diff
851
frontend/src/features/usage/components/RequestDetailDrawer.vue
Normal file
851
frontend/src/features/usage/components/RequestDetailDrawer.vue
Normal file
@@ -0,0 +1,851 @@
|
||||
<template>
|
||||
<!-- 请求详情抽屉 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="drawer">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-50 flex justify-end"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<!-- 背景遮罩 -->
|
||||
<div class="absolute inset-0 bg-black/30 backdrop-blur-sm" @click="handleClose"></div>
|
||||
|
||||
<!-- 抽屉内容 -->
|
||||
<Card class="relative h-full w-[800px] max-w-[90vw] rounded-none shadow-2xl flex flex-col">
|
||||
<!-- 固定头部 - 整合基本信息 -->
|
||||
<div class="sticky top-0 z-10 bg-background border-b px-6 py-4 flex-shrink-0">
|
||||
<!-- 第一行:标题、模型、状态、操作按钮 -->
|
||||
<div class="flex items-center justify-between gap-4 mb-3">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h3 class="text-lg font-semibold">请求详情</h3>
|
||||
<div class="flex items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
||||
<span>{{ detail?.model || '-' }}</span>
|
||||
<template v-if="detail?.target_model">
|
||||
<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" />
|
||||
</svg>
|
||||
<span>{{ detail.target_model }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<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 variant="outline" class="text-xs" v-if="detail">{{ detail.is_stream ? '流式' : '标准' }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="loading"
|
||||
@click="refreshDetail"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="handleClose" title="关闭">
|
||||
<X class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第二行:关键元信息 -->
|
||||
<div v-if="detail" class="flex items-center flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="font-medium text-foreground">ID:</span>
|
||||
<span class="font-mono">{{ detail.request_id || detail.id }}</span>
|
||||
</span>
|
||||
<span class="opacity-40">|</span>
|
||||
<span>{{ formatDateTime(detail.created_at) }}</span>
|
||||
<span class="opacity-40">|</span>
|
||||
<span>{{ formatApiFormat(detail.api_format) }}</span>
|
||||
<span class="opacity-40">|</span>
|
||||
<span>用户: {{ detail.user?.username || 'Unknown' }}</span>
|
||||
<span class="opacity-40">|</span>
|
||||
<span class="font-mono">{{ detail.api_key?.display || 'N/A' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 可滚动内容区域 -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto px-6 py-4 scrollbar-stable">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="py-8 space-y-4">
|
||||
<Skeleton class="h-8 w-full" />
|
||||
<Skeleton class="h-32 w-full" />
|
||||
<Skeleton class="h-64 w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<Card v-else-if="error" class="border-red-200 dark:border-red-800">
|
||||
<div class="p-4">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Detail Content -->
|
||||
<div v-else-if="detail" class="space-y-4">
|
||||
<!-- 费用与性能概览 -->
|
||||
<Card>
|
||||
<div class="p-4">
|
||||
<!-- 总费用和响应时间(独立显示) -->
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex items-center">
|
||||
<span class="text-xs text-muted-foreground w-[56px]">总费用</span>
|
||||
<span class="text-lg font-bold text-green-600 dark:text-green-400">
|
||||
${{ ((typeof detail.cost === 'object' ? detail.cost?.total : detail.cost) || detail.total_cost || 0).toFixed(6) }}
|
||||
</span>
|
||||
</div>
|
||||
<Separator orientation="vertical" class="h-6 mx-6" />
|
||||
<div class="flex items-center">
|
||||
<span class="text-xs text-muted-foreground w-[56px]">响应时间</span>
|
||||
<span class="text-lg font-bold">{{ detail.response_time_ms ? formatResponseTime(detail.response_time_ms).value : 'N/A' }}</span>
|
||||
<span class="text-sm text-muted-foreground ml-1">{{ detail.response_time_ms ? formatResponseTime(detail.response_time_ms).unit : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<Separator class="mb-4" />
|
||||
|
||||
<!-- 统一使用阶梯计费展示方式 -->
|
||||
<!-- 单价信息行 -->
|
||||
<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>
|
||||
<span>总输入上下文: <span class="font-mono font-medium text-foreground">{{ formatNumber(totalInputContext) }}</span></span>
|
||||
<span class="text-muted-foreground/60">(输入 {{ formatNumber(detail.tokens?.input || detail.input_tokens || 0) }} + 缓存创建 {{ formatNumber(detail.cache_creation_input_tokens || 0) }} + 缓存读取 {{ formatNumber(detail.cache_read_input_tokens || 0) }})</span>
|
||||
<Badge v-if="displayTiers.length > 1" variant="outline" class="text-[10px] px-1.5 py-0 h-4">
|
||||
命中第 {{ currentTierIndex + 1 }} 阶
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- 统一使用阶梯展示格式 -->
|
||||
<div class="space-y-2">
|
||||
<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'"
|
||||
>
|
||||
<!-- 阶梯标题行 -->
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium" :class="index === currentTierIndex ? 'text-primary' : 'text-muted-foreground'">
|
||||
第 {{ index + 1 }} 阶
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
{{ getTierRangeText(tier, index, displayTiers) }}
|
||||
</span>
|
||||
<Badge v-if="index === currentTierIndex" variant="default" class="text-[10px] px-1.5 py-0 h-4">
|
||||
当前
|
||||
</Badge>
|
||||
</div>
|
||||
<!-- 单价信息 -->
|
||||
<div class="text-muted-foreground flex items-center gap-2">
|
||||
<span>输入 ${{ formatPrice(tier.input_price_per_1m) }}/M</span>
|
||||
<span>输出 ${{ formatPrice(tier.output_price_per_1m) }}/M</span>
|
||||
<span v-if="tier.cache_creation_price_per_1m">
|
||||
缓存创建 ${{ formatPrice(tier.cache_creation_price_per_1m) }}/M
|
||||
</span>
|
||||
<span v-if="tier.cache_read_price_per_1m">
|
||||
缓存读取 ${{ formatPrice(tier.cache_read_price_per_1m) }}/M
|
||||
</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>
|
||||
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.tokens?.input || detail.input_tokens || 0 }}</span>
|
||||
<span class="text-xs font-mono">${{ (detail.cost?.input || detail.input_cost || 0).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">{{ detail.tokens?.output || detail.output_tokens || 0 }}</span>
|
||||
<span class="text-xs font-mono">${{ (detail.cost?.output || detail.output_cost || 0).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">{{ detail.cache_creation_input_tokens || 0 }}</span>
|
||||
<span class="text-xs font-mono">${{ (detail.cache_creation_cost || 0).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">{{ detail.cache_read_input_tokens || 0 }}</span>
|
||||
<span class="text-xs font-mono">${{ (detail.cache_read_cost || 0).toFixed(6) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 按次计费 -->
|
||||
<div v-if="detail.request_cost" 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"></span>
|
||||
<span class="text-xs font-mono">${{ detail.request_cost.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>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 请求链路追踪卡片 -->
|
||||
<div v-if="detail.request_id || detail.id">
|
||||
<HorizontalRequestTimeline
|
||||
:request-id="detail.request_id || detail.id"
|
||||
:override-status-code="detail.status_code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息卡片 -->
|
||||
<Card v-if="detail.error_message" class="border-red-200 dark:border-red-800">
|
||||
<div class="p-4">
|
||||
<h4 class="text-sm font-semibold text-red-600 dark:text-red-400 mb-2">错误信息</h4>
|
||||
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||
<p class="text-sm text-red-800 dark:text-red-300">{{ detail.error_message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Tabs 区域 -->
|
||||
<Card>
|
||||
<div class="p-4">
|
||||
<Tabs v-model="activeTab" :default-value="activeTab">
|
||||
<!-- Tab + 图标工具栏同行 -->
|
||||
<div class="flex items-center justify-between border-b pb-2 mb-3">
|
||||
<!-- 左侧 Tab -->
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
v-for="tab in visibleTabs"
|
||||
:key="tab.name"
|
||||
@click="activeTab = tab.name"
|
||||
class="px-3 py-1.5 text-sm transition-colors border-b-2 -mb-[9px]"
|
||||
:class="activeTab === tab.name
|
||||
? 'border-primary text-foreground font-medium'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 右侧图标工具栏 -->
|
||||
<div class="flex items-center gap-0.5">
|
||||
<!-- 请求头专用:对比/客户端/提供商 切换组 -->
|
||||
<template v-if="activeTab === 'request-headers' && hasProviderHeaders">
|
||||
<button
|
||||
:title="'对比'"
|
||||
@click="viewMode = 'compare'"
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="viewMode === 'compare' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'"
|
||||
>
|
||||
<Columns2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
:title="'客户端'"
|
||||
@click="viewMode = 'formatted'; dataSource = 'client'"
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="viewMode === 'formatted' && dataSource === 'client' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'"
|
||||
>
|
||||
<Monitor class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
:title="'提供商'"
|
||||
@click="viewMode = 'formatted'; dataSource = 'provider'"
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="viewMode === 'formatted' && dataSource === 'provider' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'"
|
||||
>
|
||||
<Server class="w-4 h-4" />
|
||||
</button>
|
||||
<Separator orientation="vertical" class="h-4 mx-1" />
|
||||
</template>
|
||||
<!-- 展开/收缩 -->
|
||||
<button
|
||||
:title="currentExpandDepth === 0 ? '展开全部' : '收缩全部'"
|
||||
@click="currentExpandDepth === 0 ? expandAll() : collapseAll()"
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="viewMode === 'compare'
|
||||
? 'text-muted-foreground/40 cursor-not-allowed'
|
||||
: 'text-muted-foreground hover:bg-muted'"
|
||||
:disabled="viewMode === 'compare'"
|
||||
>
|
||||
<Maximize2 v-if="currentExpandDepth === 0" class="w-4 h-4" />
|
||||
<Minimize2 v-else class="w-4 h-4" />
|
||||
</button>
|
||||
<!-- 复制 -->
|
||||
<button
|
||||
:title="copiedStates[activeTab] ? '已复制' : '复制'"
|
||||
@click="copyJsonToClipboard(activeTab)"
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="viewMode === 'compare'
|
||||
? 'text-muted-foreground/40 cursor-not-allowed'
|
||||
: 'text-muted-foreground hover:bg-muted'"
|
||||
:disabled="viewMode === 'compare'"
|
||||
>
|
||||
<Check v-if="copiedStates[activeTab]" class="w-4 h-4 text-green-500" />
|
||||
<Copy v-else class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 内容 -->
|
||||
<TabsContent value="request-headers">
|
||||
<RequestHeadersContent
|
||||
:detail="detail"
|
||||
:view-mode="viewMode"
|
||||
:data-source="dataSource"
|
||||
:current-header-data="currentHeaderData"
|
||||
:current-expand-depth="currentExpandDepth"
|
||||
:has-provider-headers="hasProviderHeaders"
|
||||
:client-headers-with-diff="clientHeadersWithDiff"
|
||||
:provider-headers-with-diff="providerHeadersWithDiff"
|
||||
:header-stats="headerStats"
|
||||
:is-dark="isDark"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="request-body">
|
||||
<JsonContent
|
||||
:data="detail.request_body"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无请求体信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="response-headers">
|
||||
<JsonContent
|
||||
:data="detail.response_headers"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无响应头信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="response-body">
|
||||
<JsonContent
|
||||
:data="detail.response_body"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无响应体信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="metadata">
|
||||
<JsonContent
|
||||
:data="detail.metadata"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无元数据信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
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'
|
||||
import { Copy, Check, Maximize2, Minimize2, Columns2, RefreshCw, X, Monitor, Server } from 'lucide-vue-next'
|
||||
import { dashboardApi, type RequestDetail } from '@/api/dashboard'
|
||||
|
||||
// 子组件
|
||||
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
|
||||
import JsonContent from './RequestDetailDrawer/JsonContent.vue'
|
||||
import HorizontalRequestTimeline from './HorizontalRequestTimeline.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
requestId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const detail = ref<RequestDetail | null>(null)
|
||||
const activeTab = ref('request-body')
|
||||
const copiedStates = ref<Record<string, boolean>>({})
|
||||
const viewMode = ref<'compare' | 'formatted' | 'raw'>('compare')
|
||||
const currentExpandDepth = ref(1)
|
||||
const dataSource = ref<'client' | 'provider'>('client')
|
||||
const historicalPricing = ref<{
|
||||
input_price: string
|
||||
output_price: string
|
||||
cache_creation_price: string
|
||||
cache_read_price: string
|
||||
request_price: string
|
||||
} | null>(null)
|
||||
|
||||
// 监听标签页切换
|
||||
watch(activeTab, (newTab) => {
|
||||
if (newTab !== 'request-headers' && viewMode.value === 'compare') {
|
||||
viewMode.value = 'formatted'
|
||||
}
|
||||
})
|
||||
|
||||
// 检测暗色模式
|
||||
const isDark = computed(() => {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
})
|
||||
|
||||
// 检测是否有提供商请求头
|
||||
const hasProviderHeaders = computed(() => {
|
||||
return !!(detail.value?.provider_request_headers &&
|
||||
Object.keys(detail.value.provider_request_headers).length > 0)
|
||||
})
|
||||
|
||||
// 获取当前数据源的请求头数据
|
||||
const currentHeaderData = computed(() => {
|
||||
if (!detail.value) return null
|
||||
return dataSource.value === 'client'
|
||||
? detail.value.request_headers
|
||||
: detail.value.provider_request_headers
|
||||
})
|
||||
|
||||
// 价格来源标签
|
||||
// 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 '历史定价'
|
||||
})
|
||||
|
||||
// 统一的阶梯显示数据
|
||||
// 如果有 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,
|
||||
input_price_per_1m: detail.value.input_price_per_1m || 0,
|
||||
output_price_per_1m: detail.value.output_price_per_1m || 0,
|
||||
cache_creation_price_per_1m: detail.value.cache_creation_price_per_1m,
|
||||
cache_read_price_per_1m: detail.value.cache_read_price_per_1m
|
||||
}]
|
||||
})
|
||||
|
||||
// 当前命中的阶梯索引
|
||||
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
|
||||
}
|
||||
|
||||
// 单阶梯时默认是第0阶
|
||||
return 0
|
||||
})
|
||||
|
||||
// 总输入上下文(输入 + 缓存创建 + 缓存读取)
|
||||
const totalInputContext = computed(() => {
|
||||
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
|
||||
const cacheCreation = detail.value.cache_creation_input_tokens || 0
|
||||
const cacheRead = detail.value.cache_read_input_tokens || 0
|
||||
return input + cacheCreation + cacheRead
|
||||
})
|
||||
|
||||
const tabs = [
|
||||
{ name: 'request-headers', label: '请求头' },
|
||||
{ name: 'request-body', label: '请求体' },
|
||||
{ name: 'response-headers', label: '响应头' },
|
||||
{ name: 'response-body', label: '响应体' },
|
||||
{ name: 'metadata', label: '元数据' },
|
||||
]
|
||||
|
||||
// 根据实际数据决定显示哪些 Tab
|
||||
const visibleTabs = computed(() => {
|
||||
if (!detail.value) return []
|
||||
|
||||
return tabs.filter(tab => {
|
||||
switch (tab.name) {
|
||||
case 'request-headers':
|
||||
return detail.value!.request_headers && Object.keys(detail.value!.request_headers).length > 0
|
||||
case 'request-body':
|
||||
return detail.value!.request_body !== null && detail.value!.request_body !== undefined
|
||||
case 'response-headers':
|
||||
return detail.value!.response_headers && Object.keys(detail.value!.response_headers).length > 0
|
||||
case 'response-body':
|
||||
return detail.value!.response_body !== null && detail.value!.response_body !== undefined
|
||||
case 'metadata':
|
||||
return detail.value!.metadata && Object.keys(detail.value!.metadata).length > 0
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
async function loadDetail(id: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
historicalPricing.value = null
|
||||
try {
|
||||
detail.value = await dashboardApi.getRequestDetail(id)
|
||||
|
||||
// 默认显示有内容的第一个可见 tab
|
||||
const visibleTabNames = visibleTabs.value.map(t => t.name)
|
||||
if (detail.value.request_body && visibleTabNames.includes('request-body')) {
|
||||
activeTab.value = 'request-body'
|
||||
} else if (detail.value.response_body && visibleTabNames.includes('response-body')) {
|
||||
activeTab.value = 'response-body'
|
||||
} else if (visibleTabNames.length > 0) {
|
||||
activeTab.value = visibleTabNames[0]
|
||||
}
|
||||
|
||||
// 使用请求记录中保存的历史价格
|
||||
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'
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load request detail:', err)
|
||||
error.value = '加载请求详情失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function refreshDetail() {
|
||||
if (props.requestId) {
|
||||
await loadDetail(props.requestId)
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
})
|
||||
}
|
||||
|
||||
function formatApiFormat(format: string | null | undefined): string {
|
||||
if (!format) return '-'
|
||||
const formatMap: Record<string, string> = {
|
||||
'CLAUDE': 'Claude',
|
||||
'CLAUDE_CLI': 'Claude CLI',
|
||||
'OPENAI': 'OpenAI',
|
||||
'OPENAI_CLI': 'OpenAI CLI',
|
||||
'GEMINI': 'Gemini',
|
||||
'GEMINI_CLI': 'Gemini CLI',
|
||||
}
|
||||
return formatMap[format.toUpperCase()] || format
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1_000_000) {
|
||||
return (num / 1_000_000).toFixed(1) + 'M'
|
||||
} else if (num >= 1_000) {
|
||||
return (num / 1_000).toFixed(1) + 'K'
|
||||
}
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
// 格式化响应时间,自动选择合适的单位
|
||||
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`
|
||||
}
|
||||
|
||||
function copyJsonToClipboard(tabName: string) {
|
||||
if (!detail.value) return
|
||||
// 对比模式下不允许复制
|
||||
if (viewMode.value === 'compare') return
|
||||
|
||||
let data: any = null
|
||||
switch (tabName) {
|
||||
case 'request-headers':
|
||||
// 根据当前数据源选择要复制的数据
|
||||
data = dataSource.value === 'provider'
|
||||
? detail.value.provider_request_headers
|
||||
: detail.value.request_headers
|
||||
break
|
||||
case 'request-body':
|
||||
data = detail.value.request_body
|
||||
break
|
||||
case 'response-headers':
|
||||
data = detail.value.response_headers
|
||||
break
|
||||
case 'response-body':
|
||||
data = detail.value.response_body
|
||||
break
|
||||
case 'metadata':
|
||||
data = detail.value.metadata
|
||||
break
|
||||
}
|
||||
|
||||
if (data) {
|
||||
navigator.clipboard.writeText(JSON.stringify(data, null, 2))
|
||||
copiedStates.value[tabName] = true
|
||||
setTimeout(() => {
|
||||
copiedStates.value[tabName] = false
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
currentExpandDepth.value = 999
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
currentExpandDepth.value = 0
|
||||
}
|
||||
|
||||
// 请求头合并对比逻辑
|
||||
interface HeaderEntry {
|
||||
key: string
|
||||
status: 'added' | 'modified' | 'removed' | 'unchanged'
|
||||
originalValue?: any
|
||||
newValue?: any
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
const clientHeadersWithDiff = computed(() => {
|
||||
if (!detail.value?.request_headers) return []
|
||||
|
||||
const headers = detail.value.request_headers
|
||||
const result = []
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
const diffEntry = mergedHeaderEntries.value.find(e => e.key === key)
|
||||
result.push({
|
||||
key,
|
||||
value,
|
||||
status: diffEntry?.status || 'unchanged'
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const providerHeadersWithDiff = computed(() => {
|
||||
if (!detail.value?.provider_request_headers) return []
|
||||
|
||||
const headers = detail.value.provider_request_headers
|
||||
const result = []
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
const diffEntry = mergedHeaderEntries.value.find(e => e.key === key)
|
||||
result.push({
|
||||
key,
|
||||
value,
|
||||
status: diffEntry?.status || 'unchanged'
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
</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);
|
||||
}
|
||||
</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>
|
||||
@@ -0,0 +1,505 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!data || (typeof data === 'object' && Object.keys(data).length === 0)" class="text-sm text-muted-foreground">
|
||||
{{ emptyMessage }}
|
||||
</div>
|
||||
<!-- 纯字符串数据(非 JSON 对象) -->
|
||||
<Card v-else-if="typeof data === 'string'" class="bg-muted/30 overflow-hidden">
|
||||
<div class="p-4 overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<pre class="text-xs font-mono whitespace-pre-wrap">{{ data }}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
<!-- 非 JSON 响应(如 HTML 错误页面) -->
|
||||
<Card v-else-if="data.raw_response && data.metadata?.parse_error" class="bg-muted/30 overflow-hidden">
|
||||
<div class="p-3 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-amber-600 dark:text-amber-400 text-sm font-medium">Warning: 响应解析失败</span>
|
||||
<span class="text-xs text-amber-700 dark:text-amber-300">{{ data.metadata.parse_error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<pre class="text-xs font-mono whitespace-pre-wrap text-muted-foreground">{{ data.raw_response }}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
<Card v-else class="bg-muted/30 overflow-hidden">
|
||||
<!-- JSON 查看器 -->
|
||||
<div class="json-viewer" :class="{ 'theme-dark': isDark }">
|
||||
<div class="json-lines">
|
||||
<template v-for="line in visibleLines" :key="line.displayId">
|
||||
<div class="json-line" :class="{ 'has-fold': line.canFold }">
|
||||
<!-- 行号区域(包含折叠按钮) -->
|
||||
<div class="line-number-area">
|
||||
<span
|
||||
v-if="line.canFold"
|
||||
class="fold-button"
|
||||
@click="toggleFold(line.blockId)"
|
||||
>
|
||||
<ChevronRight v-if="collapsedBlocks.has(line.blockId)" class="fold-icon" />
|
||||
<ChevronDown v-else class="fold-icon" />
|
||||
</span>
|
||||
<span class="line-number">{{ line.displayLineNumber }}</span>
|
||||
</div>
|
||||
<!-- 内容区域 -->
|
||||
<div class="line-content-area">
|
||||
<!-- 缩进 -->
|
||||
<span class="indent" :style="{ width: `${line.indent * 16}px` }"></span>
|
||||
<!-- 内容 -->
|
||||
<span
|
||||
class="line-content"
|
||||
:class="{ 'clickable-collapsed': line.canFold && collapsedBlocks.has(line.blockId) }"
|
||||
@click="line.canFold && collapsedBlocks.has(line.blockId) && toggleFold(line.blockId)"
|
||||
v-html="getDisplayHtml(line)"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ChevronRight, ChevronDown } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
|
||||
interface JsonLine {
|
||||
id: number
|
||||
lineNumber: number
|
||||
indent: number
|
||||
html: string
|
||||
canFold: boolean
|
||||
blockId: string
|
||||
blockEnd?: number
|
||||
collapsedInfo?: string
|
||||
closingBracket?: string
|
||||
trailingComma?: string
|
||||
}
|
||||
|
||||
interface DisplayLine extends JsonLine {
|
||||
displayId: string
|
||||
displayLineNumber: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
data: any
|
||||
viewMode: 'formatted' | 'raw' | 'compare'
|
||||
expandDepth: number
|
||||
isDark: boolean
|
||||
emptyMessage: string
|
||||
}>()
|
||||
|
||||
const collapsedBlocks = ref<Set<string>>(new Set())
|
||||
const lines = ref<JsonLine[]>([])
|
||||
|
||||
const getTokenHtml = (value: string, type: 'key' | 'string' | 'number' | 'boolean' | 'null' | 'bracket' | 'punctuation' | 'ellipsis'): string => {
|
||||
const classMap = {
|
||||
key: 'token-key',
|
||||
string: 'token-string',
|
||||
number: 'token-number',
|
||||
boolean: 'token-boolean',
|
||||
null: 'token-null',
|
||||
bracket: 'token-bracket',
|
||||
punctuation: 'token-punctuation',
|
||||
ellipsis: 'token-ellipsis',
|
||||
}
|
||||
return `<span class="${classMap[type]}">${escapeHtml(value)}</span>`
|
||||
}
|
||||
|
||||
const escapeHtml = (str: string): string => {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
const parseJsonToLines = (data: any): JsonLine[] => {
|
||||
const result: JsonLine[] = []
|
||||
let lineNumber = 1
|
||||
let blockIdCounter = 0
|
||||
|
||||
const getBlockId = () => `block-${blockIdCounter++}`
|
||||
|
||||
const processValue = (value: any, indent: number, isLast: boolean, keyPrefix: string = ''): void => {
|
||||
const comma = isLast ? '' : ','
|
||||
|
||||
if (value === null) {
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml('null', 'null') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
} else if (typeof value === 'boolean') {
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml(String(value), 'boolean') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
} else if (typeof value === 'number') {
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml(String(value), 'number') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
} else if (typeof value === 'string') {
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml(`"${escapeHtml(value)}"`, 'string') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
} else if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml('[]', 'bracket') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
} else {
|
||||
const blockId = getBlockId()
|
||||
const startLine = result.length
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml('[', 'bracket'),
|
||||
canFold: true,
|
||||
blockId,
|
||||
collapsedInfo: `${value.length} items`,
|
||||
closingBracket: ']',
|
||||
trailingComma: comma,
|
||||
})
|
||||
|
||||
value.forEach((item, i) => {
|
||||
processValue(item, indent + 1, i === value.length - 1)
|
||||
})
|
||||
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: getTokenHtml(']', 'bracket') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
|
||||
result[startLine].blockEnd = result.length - 1
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
const keys = Object.keys(value)
|
||||
if (keys.length === 0) {
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml('{}', 'bracket') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
} else {
|
||||
const blockId = getBlockId()
|
||||
const startLine = result.length
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml('{', 'bracket'),
|
||||
canFold: true,
|
||||
blockId,
|
||||
collapsedInfo: `${keys.length} keys`,
|
||||
closingBracket: '}',
|
||||
trailingComma: comma,
|
||||
})
|
||||
|
||||
keys.forEach((key, i) => {
|
||||
const keyHtml = getTokenHtml(`"${escapeHtml(key)}"`, 'key') + getTokenHtml(': ', 'punctuation')
|
||||
processValue(value[key], indent + 1, i === keys.length - 1, keyHtml)
|
||||
})
|
||||
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: getTokenHtml('}', 'bracket') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
|
||||
result[startLine].blockEnd = result.length - 1
|
||||
}
|
||||
} else {
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml(String(value), 'string') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
processValue(data, 0, true)
|
||||
return result
|
||||
}
|
||||
|
||||
const visibleLines = computed((): DisplayLine[] => {
|
||||
const result: DisplayLine[] = []
|
||||
const hiddenRanges: Array<{ start: number; end: number }> = []
|
||||
|
||||
for (const line of lines.value) {
|
||||
if (line.canFold && collapsedBlocks.value.has(line.blockId) && line.blockEnd !== undefined) {
|
||||
hiddenRanges.push({ start: line.id + 1, end: line.blockEnd })
|
||||
}
|
||||
}
|
||||
|
||||
const isHidden = (id: number): boolean => {
|
||||
return hiddenRanges.some(range => id >= range.start && id <= range.end)
|
||||
}
|
||||
|
||||
let displayLineNumber = 1
|
||||
for (const line of lines.value) {
|
||||
if (!isHidden(line.id)) {
|
||||
result.push({
|
||||
...line,
|
||||
displayId: `display-${line.id}`,
|
||||
displayLineNumber: displayLineNumber++,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const getDisplayHtml = (line: DisplayLine): string => {
|
||||
if (line.canFold && collapsedBlocks.value.has(line.blockId)) {
|
||||
const closingBracket = getTokenHtml(line.closingBracket || '}', 'bracket')
|
||||
const ellipsis = getTokenHtml('...', 'ellipsis')
|
||||
const comma = line.trailingComma || ''
|
||||
return `${line.html}${ellipsis}${closingBracket}${comma}<span class="collapsed-info">${line.collapsedInfo}</span>`
|
||||
}
|
||||
return line.html
|
||||
}
|
||||
|
||||
const toggleFold = (blockId: string) => {
|
||||
const newSet = new Set(collapsedBlocks.value)
|
||||
if (newSet.has(blockId)) {
|
||||
newSet.delete(blockId)
|
||||
} else {
|
||||
newSet.add(blockId)
|
||||
}
|
||||
collapsedBlocks.value = newSet
|
||||
}
|
||||
|
||||
const initCollapsedState = () => {
|
||||
const newSet = new Set<string>()
|
||||
|
||||
// 默认展开第一层(indent = 0),折叠更深层(indent >= 1)
|
||||
// expandDepth = 999 表示全部展开
|
||||
const depth = props.expandDepth === 0 ? 1 : props.expandDepth
|
||||
if (depth < 999) {
|
||||
for (const line of lines.value) {
|
||||
if (line.canFold && line.indent >= depth) {
|
||||
newSet.add(line.blockId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collapsedBlocks.value = newSet
|
||||
}
|
||||
|
||||
watch(() => props.data, () => {
|
||||
if (props.data) {
|
||||
lines.value = parseJsonToLines(props.data)
|
||||
initCollapsedState()
|
||||
} else {
|
||||
lines.value = []
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => props.expandDepth, () => {
|
||||
initCollapsedState()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.json-viewer {
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.json-lines {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.json-line {
|
||||
display: flex;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.json-line:hover {
|
||||
background: hsl(var(--muted) / 0.4);
|
||||
}
|
||||
|
||||
.line-number-area {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 8px;
|
||||
background: hsl(var(--muted) / 0.2);
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fold-button {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--muted-foreground) / 0.6);
|
||||
margin-right: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.fold-button:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted) / 0.8);
|
||||
}
|
||||
|
||||
.fold-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
color: hsl(var(--muted-foreground) / 0.5);
|
||||
min-width: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.line-content-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.indent {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.line-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.line-content.clickable-collapsed {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.line-content.clickable-collapsed:hover :deep(.token-ellipsis) {
|
||||
background: hsl(var(--primary) / 0.2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Token 颜色 - 亮色主题 */
|
||||
:deep(.token-key) {
|
||||
color: #0451a5;
|
||||
}
|
||||
|
||||
:deep(.token-string) {
|
||||
color: #a31515;
|
||||
}
|
||||
|
||||
:deep(.token-number) {
|
||||
color: #098658;
|
||||
}
|
||||
|
||||
:deep(.token-boolean) {
|
||||
color: #0000ff;
|
||||
}
|
||||
|
||||
:deep(.token-null) {
|
||||
color: #0000ff;
|
||||
}
|
||||
|
||||
:deep(.token-bracket) {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
:deep(.token-punctuation) {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
:deep(.token-ellipsis) {
|
||||
color: #0451a5;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
:deep(.collapsed-info) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-style: italic;
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Token 颜色 - 暗色主题 */
|
||||
.theme-dark :deep(.token-key) {
|
||||
color: #9cdcfe;
|
||||
}
|
||||
|
||||
.theme-dark :deep(.token-string) {
|
||||
color: #ce9178;
|
||||
}
|
||||
|
||||
.theme-dark :deep(.token-number) {
|
||||
color: #b5cea8;
|
||||
}
|
||||
|
||||
.theme-dark :deep(.token-boolean) {
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
.theme-dark :deep(.token-null) {
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
.theme-dark :deep(.token-bracket) {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.theme-dark :deep(.token-punctuation) {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.theme-dark :deep(.token-ellipsis) {
|
||||
color: #9cdcfe;
|
||||
}
|
||||
|
||||
.theme-dark .line-number-area {
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 对比模式 - 并排 Diff -->
|
||||
<div v-show="viewMode === 'compare'">
|
||||
<div v-if="!detail.request_headers && !detail.provider_request_headers" class="text-sm text-muted-foreground">
|
||||
无请求头信息
|
||||
</div>
|
||||
<Card v-else class="bg-muted/30 overflow-hidden">
|
||||
<!-- Diff 头部 -->
|
||||
<div class="flex border-b bg-muted/50">
|
||||
<div class="flex-1 px-3 py-2 text-xs text-muted-foreground border-r flex items-center justify-between">
|
||||
<span class="font-medium">客户端请求头</span>
|
||||
<span class="text-destructive">-{{ headerStats.removed + headerStats.modified }}</span>
|
||||
</div>
|
||||
<div class="flex-1 px-3 py-2 text-xs text-muted-foreground flex items-center justify-between">
|
||||
<span class="font-medium">提供商请求头</span>
|
||||
<span class="text-green-600 dark:text-green-400">+{{ headerStats.added + headerStats.modified }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 并排 Diff 内容 -->
|
||||
<div class="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<div class="flex font-mono text-xs">
|
||||
<!-- 左侧:客户端 -->
|
||||
<div class="flex-1 border-r">
|
||||
<template v-for="entry in sortedEntries" :key="'left-' + entry.key">
|
||||
<!-- 删除的行 -->
|
||||
<div v-if="entry.status === 'removed'" class="flex items-start bg-destructive/10 px-3 py-0.5">
|
||||
<span class="text-destructive">
|
||||
"{{ entry.key }}": "{{ entry.clientValue }}"
|
||||
</span>
|
||||
</div>
|
||||
<!-- 修改的行 - 旧值 -->
|
||||
<div v-else-if="entry.status === 'modified'" class="flex items-start bg-amber-500/10 px-3 py-0.5">
|
||||
<span class="text-amber-600 dark:text-amber-400">
|
||||
"{{ entry.key }}": "{{ entry.clientValue }}"
|
||||
</span>
|
||||
</div>
|
||||
<!-- 新增的行 - 左侧空白占位 -->
|
||||
<div v-else-if="entry.status === 'added'" class="flex items-start bg-muted/30 px-3 py-0.5">
|
||||
<span class="text-muted-foreground/30 italic">(无)</span>
|
||||
</div>
|
||||
<!-- 未变化的行 -->
|
||||
<div v-else class="flex items-start px-3 py-0.5 hover:bg-muted/50">
|
||||
<span class="text-muted-foreground">
|
||||
"{{ entry.key }}": "{{ entry.clientValue }}"
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 右侧:提供商 -->
|
||||
<div class="flex-1">
|
||||
<template v-for="entry in sortedEntries" :key="'right-' + entry.key">
|
||||
<!-- 删除的行 - 右侧空白占位 -->
|
||||
<div v-if="entry.status === 'removed'" class="flex items-start bg-muted/30 px-3 py-0.5">
|
||||
<span class="text-muted-foreground/50 line-through">
|
||||
"{{ entry.key }}": "{{ entry.clientValue }}"
|
||||
</span>
|
||||
</div>
|
||||
<!-- 修改的行 - 新值 -->
|
||||
<div v-else-if="entry.status === 'modified'" class="flex items-start bg-amber-500/10 px-3 py-0.5">
|
||||
<span class="text-amber-600 dark:text-amber-400">
|
||||
"{{ entry.key }}": "{{ entry.providerValue }}"
|
||||
</span>
|
||||
</div>
|
||||
<!-- 新增的行 -->
|
||||
<div v-else-if="entry.status === 'added'" class="flex items-start bg-green-500/10 px-3 py-0.5">
|
||||
<span class="text-green-600 dark:text-green-400">
|
||||
"{{ entry.key }}": "{{ entry.providerValue }}"
|
||||
</span>
|
||||
</div>
|
||||
<!-- 未变化的行 -->
|
||||
<div v-else class="flex items-start px-3 py-0.5 hover:bg-muted/50">
|
||||
<span class="text-muted-foreground">
|
||||
"{{ entry.key }}": "{{ entry.providerValue }}"
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 格式化模式 - 直接使用 JsonContent -->
|
||||
<div v-show="viewMode === 'formatted'">
|
||||
<JsonContent
|
||||
:data="currentHeaderData"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无请求头信息"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 原始模式 -->
|
||||
<div v-show="viewMode === 'raw'">
|
||||
<div v-if="!currentHeaderData || Object.keys(currentHeaderData).length === 0" class="text-sm text-muted-foreground">
|
||||
无请求头信息
|
||||
</div>
|
||||
<Card v-else class="bg-muted/30">
|
||||
<div class="p-4 overflow-x-auto">
|
||||
<pre class="text-xs font-mono whitespace-pre-wrap">{{ JSON.stringify(currentHeaderData, null, 2) }}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import JsonContent from './JsonContent.vue'
|
||||
import type { RequestDetail } from '@/api/dashboard'
|
||||
|
||||
const props = defineProps<{
|
||||
detail: RequestDetail
|
||||
viewMode: 'compare' | 'formatted' | 'raw'
|
||||
dataSource: 'client' | 'provider'
|
||||
currentHeaderData: any
|
||||
currentExpandDepth: number
|
||||
hasProviderHeaders: boolean
|
||||
clientHeadersWithDiff: Array<{ key: string; value: any; status: string }>
|
||||
providerHeadersWithDiff: Array<{ key: string; value: any; status: string }>
|
||||
headerStats: { added: number; modified: number; removed: number; unchanged: number }
|
||||
isDark: boolean
|
||||
}>()
|
||||
|
||||
// 合并并排序的条目(用于并排显示)
|
||||
const sortedEntries = computed(() => {
|
||||
const clientHeaders = props.detail.request_headers || {}
|
||||
const providerHeaders = props.detail.provider_request_headers || {}
|
||||
|
||||
const clientKeys = new Set(Object.keys(clientHeaders))
|
||||
const providerKeys = new Set(Object.keys(providerHeaders))
|
||||
const allKeys = Array.from(new Set([...clientKeys, ...providerKeys])).sort()
|
||||
|
||||
return allKeys.map(key => {
|
||||
const inClient = clientKeys.has(key)
|
||||
const inProvider = providerKeys.has(key)
|
||||
const clientValue = clientHeaders[key]
|
||||
const providerValue = providerHeaders[key]
|
||||
|
||||
let status: 'added' | 'removed' | 'modified' | 'unchanged'
|
||||
if (inClient && inProvider) {
|
||||
status = clientValue === providerValue ? 'unchanged' : 'modified'
|
||||
} else if (inClient) {
|
||||
status = 'removed'
|
||||
} else {
|
||||
status = 'added'
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
clientValue,
|
||||
providerValue,
|
||||
status
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="px-3 py-2 border-b">
|
||||
<h3 class="text-sm font-medium">按API格式分析</h3>
|
||||
</div>
|
||||
<Table class="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">API格式</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">请求数</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">Tokens</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">费用</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">平均响应</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="data.length === 0">
|
||||
<TableCell :colspan="5" class="text-center py-6 text-muted-foreground px-2">
|
||||
暂无API格式统计数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="item in data" :key="item.api_format">
|
||||
<TableCell class="font-medium py-2 px-2">
|
||||
{{ formatApiFormat(item.api_format) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">{{ item.request_count }}</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span>{{ formatTokens(item.total_tokens) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(item.total_cost) }}</span>
|
||||
<span v-if="isAdmin && item.actual_cost !== undefined" class="text-muted-foreground text-[10px]">
|
||||
{{ formatCurrency(item.actual_cost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-muted-foreground py-2 px-2">{{ item.avgResponseTime }}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import type { ApiFormatStatsItem } from '../types'
|
||||
|
||||
// 格式化 API 格式显示名称
|
||||
function formatApiFormat(format: string): string {
|
||||
const formatMap: Record<string, string> = {
|
||||
'CLAUDE': 'Claude',
|
||||
'CLAUDE_CLI': 'Claude CLI',
|
||||
'OPENAI': 'OpenAI',
|
||||
'OPENAI_CLI': 'OpenAI CLI',
|
||||
'GEMINI': 'Gemini',
|
||||
'GEMINI_CLI': 'Gemini CLI',
|
||||
}
|
||||
return formatMap[format.toUpperCase()] || format
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
data: ApiFormatStatsItem[]
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
</script>
|
||||
61
frontend/src/features/usage/components/UsageModelTable.vue
Normal file
61
frontend/src/features/usage/components/UsageModelTable.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="px-3 py-2 border-b">
|
||||
<h3 class="text-sm font-medium">按模型分析</h3>
|
||||
</div>
|
||||
<Table class="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">模型</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">请求数</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">Tokens</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">费用</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">效率</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="data.length === 0">
|
||||
<TableCell :colspan="5" class="text-center py-6 text-muted-foreground px-2">
|
||||
暂无模型统计数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="model in data" :key="model.model">
|
||||
<TableCell class="font-medium py-2 px-2">
|
||||
{{ model.model.replace('claude-', '') }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">{{ model.request_count }}</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span>{{ formatTokens(model.total_tokens) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(model.total_cost) }}</span>
|
||||
<span v-if="isAdmin && model.actual_cost !== undefined" class="text-muted-foreground text-[10px]">
|
||||
{{ formatCurrency(model.actual_cost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-muted-foreground py-2 px-2">{{ model.costPerToken }}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import type { EnhancedModelStatsItem } from '../types'
|
||||
|
||||
defineProps<{
|
||||
data: EnhancedModelStatsItem[]
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="px-3 py-2 border-b">
|
||||
<h3 class="text-sm font-medium">按提供商分析</h3>
|
||||
</div>
|
||||
<Table class="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">提供商</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">请求数</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">Tokens</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">费用</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">成功率</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">平均响应</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="data.length === 0">
|
||||
<TableCell :colspan="6" class="text-center py-6 text-muted-foreground px-2">
|
||||
暂无提供商统计数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="provider in data" :key="provider.provider">
|
||||
<TableCell class="font-medium py-2 px-2">{{ provider.provider }}</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">{{ provider.requests }}</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span>{{ formatTokens(provider.totalTokens) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(provider.totalCost) }}</span>
|
||||
<span v-if="isAdmin && provider.actualCost !== undefined" class="text-muted-foreground text-[10px]">
|
||||
{{ formatCurrency(provider.actualCost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span :class="getSuccessRateClass(provider.successRate)">{{ provider.successRate }}%</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-muted-foreground py-2 px-2">{{ provider.avgResponseTime }}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import type { ProviderStatsItem } from '../types'
|
||||
|
||||
// 成功率样式 - 简化为两种状态
|
||||
function getSuccessRateClass(rate: number): string {
|
||||
if (rate < 90) return 'text-destructive'
|
||||
return '' // 默认颜色
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
data: ProviderStatsItem[]
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
</script>
|
||||
424
frontend/src/features/usage/components/UsageRecordsTable.vue
Normal file
424
frontend/src/features/usage/components/UsageRecordsTable.vue
Normal file
@@ -0,0 +1,424 @@
|
||||
<template>
|
||||
<TableCard title="使用记录">
|
||||
<template #actions>
|
||||
<!-- 时间段筛选 -->
|
||||
<Select
|
||||
:model-value="selectedPeriod"
|
||||
v-model:open="periodSelectOpen"
|
||||
@update:model-value="$emit('update:selectedPeriod', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-32 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="选择时间段" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="today">今天</SelectItem>
|
||||
<SelectItem value="yesterday">昨天</SelectItem>
|
||||
<SelectItem value="last7days">最近7天</SelectItem>
|
||||
<SelectItem value="last30days">最近30天</SelectItem>
|
||||
<SelectItem value="last90days">最近90天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 用户筛选(仅管理员可见) -->
|
||||
<Select
|
||||
v-if="isAdmin && availableUsers.length > 0"
|
||||
:model-value="filterUser"
|
||||
v-model:open="filterUserSelectOpen"
|
||||
@update:model-value="$emit('update:filterUser', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-36 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部用户" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">全部用户</SelectItem>
|
||||
<SelectItem v-for="user in availableUsers" :key="user.id" :value="user.id">
|
||||
{{ user.username || user.email }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 模型筛选 -->
|
||||
<Select
|
||||
:model-value="filterModel"
|
||||
v-model:open="filterModelSelectOpen"
|
||||
@update:model-value="$emit('update:filterModel', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-40 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">全部模型</SelectItem>
|
||||
<SelectItem v-for="model in availableModels" :key="model" :value="model">
|
||||
{{ model.replace('claude-', '') }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 提供商筛选 -->
|
||||
<Select
|
||||
:model-value="filterProvider"
|
||||
v-model:open="filterProviderSelectOpen"
|
||||
@update:model-value="$emit('update:filterProvider', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-32 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部提供商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">全部提供商</SelectItem>
|
||||
<SelectItem v-for="provider in availableProviders" :key="provider" :value="provider">
|
||||
{{ provider }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<Select
|
||||
:model-value="filterStatus"
|
||||
v-model:open="filterStatusSelectOpen"
|
||||
@update:model-value="$emit('update:filterStatus', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">全部状态</SelectItem>
|
||||
<SelectItem value="active">进行中</SelectItem>
|
||||
<SelectItem value="pending">等待中</SelectItem>
|
||||
<SelectItem value="streaming">流式传输</SelectItem>
|
||||
<SelectItem value="completed">已完成</SelectItem>
|
||||
<SelectItem value="failed">已失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<RefreshButton :loading="loading" @click="$emit('refresh')" />
|
||||
</template>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="h-12 font-semibold w-[70px]">时间</TableHead>
|
||||
<TableHead v-if="isAdmin" class="h-12 font-semibold w-[100px]">用户</TableHead>
|
||||
<TableHead class="h-12 font-semibold w-[140px]">模型</TableHead>
|
||||
<TableHead v-if="isAdmin" class="h-12 font-semibold w-[100px]">提供商</TableHead>
|
||||
<TableHead class="h-12 font-semibold w-[80px]">API格式</TableHead>
|
||||
<TableHead class="h-12 font-semibold w-[50px] text-center">类型</TableHead>
|
||||
<TableHead class="h-12 font-semibold w-[140px] text-right">Tokens</TableHead>
|
||||
<TableHead class="h-12 font-semibold w-[100px] text-right">费用</TableHead>
|
||||
<TableHead class="h-12 font-semibold w-[70px] text-right">
|
||||
<div class="inline-block max-w-[2rem] leading-tight">响应时间</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="records.length === 0">
|
||||
<TableCell :colspan="isAdmin ? 9 : 7" class="text-center py-12 text-muted-foreground">
|
||||
暂无请求记录
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-else
|
||||
v-for="record in records"
|
||||
:key="record.id"
|
||||
@mousedown="handleMouseDown"
|
||||
@click="handleRowClick($event, record.id)"
|
||||
:class="isAdmin ? 'cursor-pointer border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]' : 'border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]'"
|
||||
>
|
||||
<TableCell class="text-xs py-4 w-[70px]">
|
||||
{{ formatDateTime(record.created_at) }}
|
||||
</TableCell>
|
||||
<TableCell v-if="isAdmin" class="py-4 w-[100px] truncate" :title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')">
|
||||
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
|
||||
</TableCell>
|
||||
<TableCell class="font-medium py-4 w-[140px]" :title="getModelTooltip(record)">
|
||||
<div v-if="getActualModel(record)" class="flex flex-col text-xs gap-0.5">
|
||||
<div class="flex items-center gap-1 truncate">
|
||||
<span class="truncate">{{ record.model }}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3 text-muted-foreground 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" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-muted-foreground truncate">{{ getActualModel(record) }}</span>
|
||||
</div>
|
||||
<span v-else class="truncate block">{{ record.model }}</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="isAdmin" class="py-4 w-[60px]">
|
||||
<div class="flex flex-col text-xs gap-0.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ record.provider }}</span>
|
||||
<span
|
||||
v-if="record.has_fallback"
|
||||
class="inline-flex items-center justify-center w-4 h-4 text-xs text-amber-600 dark:text-amber-400"
|
||||
title="此请求发生了 Provider 切换"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="record.api_key_name" class="text-muted-foreground truncate" :title="record.api_key_name">
|
||||
{{ record.api_key_name }}
|
||||
<span v-if="record.rate_multiplier && record.rate_multiplier !== 1.0" class="text-foreground/60">({{ record.rate_multiplier }}x)</span>
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 w-[80px]">
|
||||
<span
|
||||
v-if="record.api_format"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full border border-border/60 text-[10px] font-medium whitespace-nowrap text-muted-foreground"
|
||||
:title="record.api_format"
|
||||
>
|
||||
{{ formatApiFormat(record.api_format) }}
|
||||
</span>
|
||||
<span v-else class="text-muted-foreground text-xs">-</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-center py-4 w-[50px]">
|
||||
<!-- 优先显示请求状态 -->
|
||||
<Badge v-if="record.status === 'pending'" variant="outline" class="whitespace-nowrap animate-pulse border-muted-foreground/30 text-muted-foreground">
|
||||
等待中
|
||||
</Badge>
|
||||
<Badge v-else-if="record.status === 'streaming'" variant="outline" class="whitespace-nowrap animate-pulse border-primary/50 text-primary">
|
||||
传输中
|
||||
</Badge>
|
||||
<Badge v-else-if="record.status === 'failed' || (record.status_code && record.status_code >= 400) || record.error_message" variant="destructive" class="whitespace-nowrap">
|
||||
失败
|
||||
</Badge>
|
||||
<Badge v-else-if="record.is_stream" variant="secondary" class="whitespace-nowrap">
|
||||
流式
|
||||
</Badge>
|
||||
<Badge v-else variant="outline" class="whitespace-nowrap border-border/60 text-muted-foreground">
|
||||
标准
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-4 w-[140px]">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ formatTokens(record.input_tokens || 0) }}</span>
|
||||
<span class="text-muted-foreground">/</span>
|
||||
<span>{{ formatTokens(record.output_tokens || 0) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-muted-foreground">
|
||||
<span :class="record.cache_creation_input_tokens ? 'text-foreground/70' : ''">{{ record.cache_creation_input_tokens ? formatTokens(record.cache_creation_input_tokens) : '-' }}</span>
|
||||
<span>/</span>
|
||||
<span :class="record.cache_read_input_tokens ? 'text-foreground/70' : ''">{{ record.cache_read_input_tokens ? formatTokens(record.cache_read_input_tokens) : '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-4 w-[100px]">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
|
||||
<span v-if="showActualCost && record.actual_cost !== undefined" class="text-muted-foreground">
|
||||
{{ formatCurrency(record.actual_cost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-4 w-[70px]">
|
||||
<span
|
||||
v-if="record.status === 'pending' || record.status === 'streaming'"
|
||||
class="text-primary tabular-nums"
|
||||
>
|
||||
{{ getElapsedTime(record) }}
|
||||
</span>
|
||||
<span v-else-if="record.response_time_ms">
|
||||
{{ (record.response_time_ms / 1000).toFixed(2) }}s
|
||||
</span>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 分页控件 -->
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="totalRecords > 0"
|
||||
:current="currentPage"
|
||||
:total="totalRecords"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
@update:current="$emit('update:currentPage', $event)"
|
||||
@update:page-size="$emit('update:pageSize', $event)"
|
||||
/>
|
||||
</template>
|
||||
</TableCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted, watch } from 'vue'
|
||||
import TableCard from '@/components/ui/table-card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import { Pagination, RefreshButton } from '@/components/ui'
|
||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import { formatDateTime } from '../composables'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
import type { UsageRecord } from '../types'
|
||||
|
||||
export interface UserOption {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
records: UsageRecord[]
|
||||
isAdmin: boolean
|
||||
showActualCost: boolean
|
||||
loading: boolean
|
||||
// 时间段
|
||||
selectedPeriod: string
|
||||
// 筛选
|
||||
filterUser: string
|
||||
filterModel: string
|
||||
filterProvider: string
|
||||
filterStatus: string
|
||||
availableUsers: UserOption[]
|
||||
availableModels: string[]
|
||||
availableProviders: string[]
|
||||
// 分页
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
totalRecords: number
|
||||
pageSizeOptions: number[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedPeriod': [value: string]
|
||||
'update:filterUser': [value: string]
|
||||
'update:filterModel': [value: string]
|
||||
'update:filterProvider': [value: string]
|
||||
'update:filterStatus': [value: string]
|
||||
'update:currentPage': [value: number]
|
||||
'update:pageSize': [value: number]
|
||||
'refresh': []
|
||||
'showDetail': [id: string]
|
||||
}>()
|
||||
|
||||
// Select 打开状态
|
||||
const periodSelectOpen = ref(false)
|
||||
const filterUserSelectOpen = ref(false)
|
||||
const filterModelSelectOpen = ref(false)
|
||||
const filterProviderSelectOpen = ref(false)
|
||||
const filterStatusSelectOpen = ref(false)
|
||||
|
||||
// 动态计时器相关
|
||||
const now = ref(Date.now())
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 检查是否有活跃请求
|
||||
const hasActiveRecords = computed(() => {
|
||||
return props.records.some(r => r.status === 'pending' || r.status === 'streaming')
|
||||
})
|
||||
|
||||
// 启动计时器
|
||||
function startTimer() {
|
||||
if (timerInterval) return
|
||||
timerInterval = setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 100) // 每 100ms 更新一次
|
||||
}
|
||||
|
||||
// 停止计时器
|
||||
function stopTimer() {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval)
|
||||
timerInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
// 计算活跃请求的实时耗时
|
||||
function getElapsedTime(record: UsageRecord): string {
|
||||
if (record.status !== 'pending' && record.status !== 'streaming') {
|
||||
// 非活跃状态,显示实际响应时间
|
||||
if (record.response_time_ms) {
|
||||
return `${(record.response_time_ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
// 活跃状态,计算实时耗时
|
||||
if (!record.created_at) return '-'
|
||||
|
||||
const createdAt = new Date(record.created_at).getTime()
|
||||
const elapsed = now.value - createdAt
|
||||
|
||||
if (elapsed < 0) return '0.00s'
|
||||
return `${(elapsed / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
// 监听活跃记录状态,自动启动/停止计时器
|
||||
watch(hasActiveRecords, (hasActive) => {
|
||||
if (hasActive) {
|
||||
startTimer()
|
||||
} else {
|
||||
stopTimer()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 使用复用的行点击逻辑
|
||||
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
|
||||
|
||||
// 处理行点击,排除文本选择操作
|
||||
function handleRowClick(event: MouseEvent, id: string) {
|
||||
if (!props.isAdmin) return
|
||||
if (!shouldTriggerRowClick(event)) return
|
||||
emit('showDetail', id)
|
||||
}
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
stopTimer()
|
||||
})
|
||||
|
||||
// 格式化 API 格式显示名称
|
||||
function formatApiFormat(format: string): string {
|
||||
const formatMap: Record<string, string> = {
|
||||
'CLAUDE': 'Claude',
|
||||
'CLAUDE_CLI': 'Claude CLI',
|
||||
'OPENAI': 'OpenAI',
|
||||
'OPENAI_CLI': 'OpenAI CLI',
|
||||
'GEMINI': 'Gemini',
|
||||
'GEMINI_CLI': 'Gemini CLI',
|
||||
}
|
||||
return formatMap[format.toUpperCase()] || format
|
||||
}
|
||||
|
||||
// 获取实际使用的模型(优先 target_model,其次 model_version)
|
||||
function getActualModel(record: UsageRecord): string | null {
|
||||
// 优先显示模型映射
|
||||
if (record.target_model) {
|
||||
return record.target_model
|
||||
}
|
||||
// 其次显示 Provider 返回的实际版本(如 Gemini 的 modelVersion)
|
||||
if (record.request_metadata?.model_version) {
|
||||
return record.request_metadata.model_version
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取模型列的 tooltip
|
||||
function getModelTooltip(record: UsageRecord): string {
|
||||
const actualModel = getActualModel(record)
|
||||
if (actualModel) {
|
||||
return `${record.model} -> ${actualModel}`
|
||||
}
|
||||
return record.model
|
||||
}
|
||||
</script>
|
||||
7
frontend/src/features/usage/components/index.ts
Normal file
7
frontend/src/features/usage/components/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as UsageModelTable } from './UsageModelTable.vue'
|
||||
export { default as UsageProviderTable } from './UsageProviderTable.vue'
|
||||
export { default as UsageApiFormatTable } from './UsageApiFormatTable.vue'
|
||||
export { default as UsageRecordsTable } from './UsageRecordsTable.vue'
|
||||
export { default as ActivityHeatmapCard } from './ActivityHeatmapCard.vue'
|
||||
export { default as RequestDetailDrawer } from './RequestDetailDrawer.vue'
|
||||
export { default as HorizontalRequestTimeline } from './HorizontalRequestTimeline.vue'
|
||||
4
frontend/src/features/usage/composables/index.ts
Normal file
4
frontend/src/features/usage/composables/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { useUsageData } from './useUsageData'
|
||||
export { useUsageFilters } from './useUsageFilters'
|
||||
export { useUsagePagination } from './useUsagePagination'
|
||||
export { getDateRangeFromPeriod, formatDateTime, getSuccessRateColor } from './useDateRange'
|
||||
68
frontend/src/features/usage/composables/useDateRange.ts
Normal file
68
frontend/src/features/usage/composables/useDateRange.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { PeriodValue, DateRangeParams } from '../types'
|
||||
|
||||
/**
|
||||
* 格式化日期为 ISO 格式(不带毫秒,兼容 FastAPI datetime 解析)
|
||||
*/
|
||||
function formatDateForApi(date: Date): string {
|
||||
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据时间段值计算日期范围
|
||||
*/
|
||||
export function getDateRangeFromPeriod(period: PeriodValue): DateRangeParams {
|
||||
const now = new Date()
|
||||
let startDate: Date
|
||||
let endDate = new Date(now)
|
||||
|
||||
switch (period) {
|
||||
case 'today':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
break
|
||||
case 'yesterday':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||
endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
break
|
||||
case 'last7days':
|
||||
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'last30days':
|
||||
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'last90days':
|
||||
startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
default:
|
||||
return {} // 返回空对象表示不过滤时间
|
||||
}
|
||||
|
||||
return {
|
||||
start_date: formatDateForApi(startDate),
|
||||
end_date: formatDateForApi(endDate)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间为时分秒
|
||||
*/
|
||||
export function formatDateTime(dateStr: string): string {
|
||||
// 后端返回的是 UTC 时间但没有时区标识,需要手动添加 'Z'
|
||||
const utcDateStr = dateStr.includes('Z') || dateStr.includes('+') ? dateStr : dateStr + 'Z'
|
||||
const date = new Date(utcDateStr)
|
||||
|
||||
// 只显示时分秒
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
|
||||
return `${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成功率颜色类名
|
||||
*/
|
||||
export function getSuccessRateColor(rate: number): string {
|
||||
if (rate >= 95) return 'text-green-600 dark:text-green-400'
|
||||
if (rate >= 90) return 'text-yellow-600 dark:text-yellow-400'
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
314
frontend/src/features/usage/composables/useUsageData.ts
Normal file
314
frontend/src/features/usage/composables/useUsageData.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { ref, computed, watch, type Ref } from 'vue'
|
||||
import { usageApi } from '@/api/usage'
|
||||
import { meApi } from '@/api/me'
|
||||
import type {
|
||||
UsageStatsState,
|
||||
ModelStatsItem,
|
||||
ProviderStatsItem,
|
||||
ApiFormatStatsItem,
|
||||
UsageRecord,
|
||||
DateRangeParams,
|
||||
EnhancedModelStatsItem
|
||||
} from '../types'
|
||||
import { createDefaultStats } from '../types'
|
||||
|
||||
export interface UseUsageDataOptions {
|
||||
isAdminPage: Ref<boolean>
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface FilterParams {
|
||||
user_id?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export function useUsageData(options: UseUsageDataOptions) {
|
||||
const { isAdminPage } = options
|
||||
|
||||
// 加载状态
|
||||
const isLoadingStats = ref(true)
|
||||
const isLoadingRecords = ref(false)
|
||||
const loading = computed(() => isLoadingStats.value || isLoadingRecords.value)
|
||||
|
||||
// 统计数据
|
||||
const stats = ref<UsageStatsState>(createDefaultStats())
|
||||
const modelStats = ref<ModelStatsItem[]>([])
|
||||
const providerStats = ref<ProviderStatsItem[]>([])
|
||||
const apiFormatStats = ref<ApiFormatStatsItem[]>([])
|
||||
|
||||
// 记录数据 - 只存储当前页
|
||||
const currentRecords = ref<UsageRecord[]>([])
|
||||
const totalRecords = ref(0)
|
||||
|
||||
// 当前的日期范围(用于分页请求)
|
||||
const currentDateRange = ref<DateRangeParams | undefined>(undefined)
|
||||
|
||||
// 可用的筛选选项(从统计数据获取,而不是从记录中)
|
||||
const availableModels = ref<string[]>([])
|
||||
const availableProviders = ref<string[]>([])
|
||||
|
||||
// 增强的模型统计(包含效率分析)
|
||||
const enhancedModelStats = computed<EnhancedModelStatsItem[]>(() => {
|
||||
return modelStats.value.map(model => ({
|
||||
...model,
|
||||
costPerToken: model.total_tokens > 0
|
||||
? `$${(model.total_cost / model.total_tokens * 1000000).toFixed(2)}/M`
|
||||
: '-'
|
||||
}))
|
||||
})
|
||||
|
||||
// 活跃度热图数据
|
||||
const activityHeatmapData = computed(() => stats.value.activity_heatmap)
|
||||
|
||||
// 加载统计数据(不加载记录)
|
||||
async function loadStats(dateRange?: DateRangeParams) {
|
||||
isLoadingStats.value = true
|
||||
currentDateRange.value = dateRange
|
||||
|
||||
try {
|
||||
if (isAdminPage.value) {
|
||||
// 管理员页面,并行加载统计数据
|
||||
const [statsData, modelData, providerData, apiFormatData] = await Promise.all([
|
||||
usageApi.getUsageStats(dateRange),
|
||||
usageApi.getUsageByModel(dateRange),
|
||||
usageApi.getUsageByProvider(dateRange),
|
||||
usageApi.getUsageByApiFormat(dateRange)
|
||||
])
|
||||
|
||||
stats.value = {
|
||||
total_requests: statsData.total_requests || 0,
|
||||
total_tokens: statsData.total_tokens || 0,
|
||||
total_cost: statsData.total_cost || 0,
|
||||
total_actual_cost: (statsData as any).total_actual_cost,
|
||||
avg_response_time: statsData.avg_response_time || 0,
|
||||
error_count: (statsData as any).error_count,
|
||||
error_rate: (statsData as any).error_rate,
|
||||
cache_stats: (statsData as any).cache_stats,
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
activity_heatmap: statsData.activity_heatmap || null
|
||||
}
|
||||
|
||||
modelStats.value = modelData.map(item => ({
|
||||
model: item.model,
|
||||
request_count: item.request_count || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
total_cost: item.total_cost || 0,
|
||||
actual_cost: (item as any).actual_cost
|
||||
}))
|
||||
|
||||
providerStats.value = providerData.map(item => ({
|
||||
provider: item.provider,
|
||||
requests: item.request_count,
|
||||
totalTokens: item.total_tokens || 0,
|
||||
totalCost: item.total_cost,
|
||||
actualCost: item.actual_cost,
|
||||
successRate: item.success_rate,
|
||||
avgResponseTime: item.avg_response_time_ms > 0
|
||||
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
|
||||
: '-'
|
||||
}))
|
||||
|
||||
apiFormatStats.value = apiFormatData.map(item => ({
|
||||
api_format: item.api_format,
|
||||
request_count: item.request_count || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
total_cost: item.total_cost || 0,
|
||||
actual_cost: item.actual_cost,
|
||||
avgResponseTime: item.avg_response_time_ms > 0
|
||||
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
|
||||
: '-'
|
||||
}))
|
||||
|
||||
// 从统计数据中提取可用的筛选选项
|
||||
availableModels.value = modelData.map(item => item.model).filter(Boolean).sort()
|
||||
availableProviders.value = providerData.map(item => item.provider).filter(Boolean).sort()
|
||||
|
||||
} else {
|
||||
// 用户页面
|
||||
const userData = await meApi.getUsage(dateRange)
|
||||
|
||||
stats.value = {
|
||||
total_requests: userData.total_requests || 0,
|
||||
total_tokens: userData.total_tokens || 0,
|
||||
total_cost: userData.total_cost || 0,
|
||||
total_actual_cost: userData.total_actual_cost,
|
||||
avg_response_time: userData.avg_response_time || 0,
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
activity_heatmap: userData.activity_heatmap || null
|
||||
}
|
||||
|
||||
modelStats.value = (userData.summary_by_model || []).map((item: any) => ({
|
||||
model: item.model,
|
||||
request_count: item.requests || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
total_cost: item.total_cost_usd || 0,
|
||||
actual_cost: item.actual_total_cost_usd
|
||||
}))
|
||||
|
||||
providerStats.value = (userData.summary_by_provider || []).map((item: any) => ({
|
||||
provider: item.provider,
|
||||
requests: item.requests || 0,
|
||||
totalCost: item.total_cost_usd || 0,
|
||||
successRate: item.success_rate || 0,
|
||||
avgResponseTime: item.avg_response_time_ms > 0
|
||||
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
|
||||
: '-'
|
||||
}))
|
||||
|
||||
// 用户页面:记录直接从 userData 获取(数量较少)
|
||||
currentRecords.value = (userData.records || []) as UsageRecord[]
|
||||
totalRecords.value = currentRecords.value.length
|
||||
|
||||
// 从记录中提取筛选选项和 API 格式统计
|
||||
const models = new Set<string>()
|
||||
const providers = new Set<string>()
|
||||
const apiFormatMap = new Map<string, {
|
||||
count: number
|
||||
tokens: number
|
||||
cost: number
|
||||
totalResponseTime: number
|
||||
responseTimeCount: number
|
||||
}>()
|
||||
|
||||
currentRecords.value.forEach(record => {
|
||||
if (record.model) models.add(record.model)
|
||||
if (record.provider) providers.add(record.provider)
|
||||
if (record.api_format) {
|
||||
const existing = apiFormatMap.get(record.api_format) || {
|
||||
count: 0,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
totalResponseTime: 0,
|
||||
responseTimeCount: 0
|
||||
}
|
||||
existing.count++
|
||||
existing.tokens += record.total_tokens || 0
|
||||
existing.cost += record.cost || 0
|
||||
if (record.response_time_ms) {
|
||||
existing.totalResponseTime += record.response_time_ms
|
||||
existing.responseTimeCount++
|
||||
}
|
||||
apiFormatMap.set(record.api_format, existing)
|
||||
}
|
||||
})
|
||||
|
||||
availableModels.value = Array.from(models).sort()
|
||||
availableProviders.value = Array.from(providers).sort()
|
||||
|
||||
// 构建 API 格式统计数据
|
||||
apiFormatStats.value = Array.from(apiFormatMap.entries())
|
||||
.map(([format, data]) => {
|
||||
const avgMs = data.responseTimeCount > 0
|
||||
? data.totalResponseTime / data.responseTimeCount
|
||||
: 0
|
||||
return {
|
||||
api_format: format,
|
||||
request_count: data.count,
|
||||
total_tokens: data.tokens,
|
||||
total_cost: data.cost,
|
||||
avgResponseTime: avgMs > 0 ? `${(avgMs / 1000).toFixed(2)}s` : '-'
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.request_count - a.request_count)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.status !== 403) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
}
|
||||
stats.value = createDefaultStats()
|
||||
modelStats.value = []
|
||||
currentRecords.value = []
|
||||
} finally {
|
||||
isLoadingStats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载记录(真正的后端分页)
|
||||
async function loadRecords(
|
||||
pagination: PaginationParams,
|
||||
filters?: FilterParams
|
||||
): Promise<void> {
|
||||
if (!isAdminPage.value) {
|
||||
// 用户页面不需要分页加载,记录已在 loadStats 中获取
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingRecords.value = true
|
||||
|
||||
try {
|
||||
const offset = (pagination.page - 1) * pagination.pageSize
|
||||
|
||||
// 构建请求参数
|
||||
const params: any = {
|
||||
limit: pagination.pageSize,
|
||||
offset,
|
||||
...currentDateRange.value
|
||||
}
|
||||
|
||||
// 添加筛选条件
|
||||
if (filters?.user_id) {
|
||||
params.user_id = filters.user_id
|
||||
}
|
||||
if (filters?.model) {
|
||||
params.model = filters.model
|
||||
}
|
||||
if (filters?.provider) {
|
||||
params.provider = filters.provider
|
||||
}
|
||||
if (filters?.status) {
|
||||
params.status = filters.status
|
||||
}
|
||||
|
||||
const response = await usageApi.getAllUsageRecords(params)
|
||||
|
||||
currentRecords.value = (response.records || []) as UsageRecord[]
|
||||
totalRecords.value = response.total || 0
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载记录失败:', error)
|
||||
currentRecords.value = []
|
||||
totalRecords.value = 0
|
||||
} finally {
|
||||
isLoadingRecords.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新所有数据
|
||||
async function refreshData(dateRange?: DateRangeParams) {
|
||||
await loadStats(dateRange)
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
loading,
|
||||
isLoadingStats,
|
||||
isLoadingRecords,
|
||||
stats,
|
||||
modelStats,
|
||||
providerStats,
|
||||
apiFormatStats,
|
||||
currentRecords,
|
||||
totalRecords,
|
||||
|
||||
// 筛选选项
|
||||
availableModels,
|
||||
availableProviders,
|
||||
|
||||
// 计算属性
|
||||
enhancedModelStats,
|
||||
activityHeatmapData,
|
||||
|
||||
// 方法
|
||||
loadStats,
|
||||
loadRecords,
|
||||
refreshData
|
||||
}
|
||||
}
|
||||
136
frontend/src/features/usage/composables/useUsageFilters.ts
Normal file
136
frontend/src/features/usage/composables/useUsageFilters.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { ref, computed, type Ref, watch } from 'vue'
|
||||
import type { UsageRecord, FilterStatusValue } from '../types'
|
||||
|
||||
export interface UseUsageFiltersOptions {
|
||||
/** 所有记录的响应式引用 */
|
||||
allRecords: Ref<UsageRecord[]>
|
||||
/** 当筛选变化时的回调 */
|
||||
onFilterChange?: () => void
|
||||
}
|
||||
|
||||
export function useUsageFilters(options: UseUsageFiltersOptions) {
|
||||
const { allRecords, onFilterChange } = options
|
||||
|
||||
// 筛选状态
|
||||
const filterModel = ref('__all__')
|
||||
const filterProvider = ref('__all__')
|
||||
const filterStatus = ref<FilterStatusValue>('__all__')
|
||||
|
||||
// Select 打开状态
|
||||
const filterModelSelectOpen = ref(false)
|
||||
const filterProviderSelectOpen = ref(false)
|
||||
const filterStatusSelectOpen = ref(false)
|
||||
|
||||
// 可用模型和提供商选项
|
||||
const availableModels = computed(() => {
|
||||
const models = new Set<string>()
|
||||
allRecords.value.forEach(record => {
|
||||
if (record.model) models.add(record.model)
|
||||
})
|
||||
return Array.from(models).sort()
|
||||
})
|
||||
|
||||
const availableProviders = computed(() => {
|
||||
const providers = new Set<string>()
|
||||
allRecords.value.forEach(record => {
|
||||
if (record.provider) providers.add(record.provider)
|
||||
})
|
||||
return Array.from(providers).sort()
|
||||
})
|
||||
|
||||
// 是否有活跃的筛选条件
|
||||
const hasActiveFilters = computed(() => {
|
||||
return filterModel.value !== '__all__' ||
|
||||
filterProvider.value !== '__all__' ||
|
||||
filterStatus.value !== '__all__'
|
||||
})
|
||||
|
||||
// 筛选后的记录
|
||||
const filteredRecords = computed(() => {
|
||||
if (!hasActiveFilters.value) {
|
||||
return allRecords.value
|
||||
}
|
||||
|
||||
let records = [...allRecords.value]
|
||||
|
||||
if (filterModel.value !== '__all__') {
|
||||
records = records.filter(record => record.model === filterModel.value)
|
||||
}
|
||||
|
||||
if (filterProvider.value !== '__all__') {
|
||||
records = records.filter(record => record.provider === filterProvider.value)
|
||||
}
|
||||
|
||||
if (filterStatus.value !== '__all__') {
|
||||
if (filterStatus.value === 'stream') {
|
||||
records = records.filter(record =>
|
||||
record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
|
||||
)
|
||||
} else if (filterStatus.value === 'standard') {
|
||||
records = records.filter(record =>
|
||||
!record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
|
||||
)
|
||||
} else if (filterStatus.value === 'error') {
|
||||
records = records.filter(record =>
|
||||
record.error_message || (record.status_code && record.status_code >= 400)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return records
|
||||
})
|
||||
|
||||
// 筛选后的总记录数
|
||||
const filteredTotalRecords = computed(() => filteredRecords.value.length)
|
||||
|
||||
// 处理筛选变化
|
||||
function handleFilterModelChange(value: string) {
|
||||
filterModel.value = value
|
||||
onFilterChange?.()
|
||||
}
|
||||
|
||||
function handleFilterProviderChange(value: string) {
|
||||
filterProvider.value = value
|
||||
onFilterChange?.()
|
||||
}
|
||||
|
||||
function handleFilterStatusChange(value: string) {
|
||||
filterStatus.value = value as FilterStatusValue
|
||||
onFilterChange?.()
|
||||
}
|
||||
|
||||
// 重置所有筛选
|
||||
function resetFilters() {
|
||||
filterModel.value = '__all__'
|
||||
filterProvider.value = '__all__'
|
||||
filterStatus.value = '__all__'
|
||||
onFilterChange?.()
|
||||
}
|
||||
|
||||
return {
|
||||
// 筛选状态
|
||||
filterModel,
|
||||
filterProvider,
|
||||
filterStatus,
|
||||
|
||||
// Select 打开状态
|
||||
filterModelSelectOpen,
|
||||
filterProviderSelectOpen,
|
||||
filterStatusSelectOpen,
|
||||
|
||||
// 可用选项
|
||||
availableModels,
|
||||
availableProviders,
|
||||
|
||||
// 计算属性
|
||||
hasActiveFilters,
|
||||
filteredRecords,
|
||||
filteredTotalRecords,
|
||||
|
||||
// 方法
|
||||
handleFilterModelChange,
|
||||
handleFilterProviderChange,
|
||||
handleFilterStatusChange,
|
||||
resetFilters
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import type { UsageRecord } from '../types'
|
||||
|
||||
export interface UseUsagePaginationOptions {
|
||||
/** 数据源记录 */
|
||||
records: Ref<UsageRecord[]>
|
||||
/** 初始页码 */
|
||||
initialPage?: number
|
||||
/** 初始每页大小 */
|
||||
initialPageSize?: number
|
||||
/** 每页大小选项 */
|
||||
pageSizeOptions?: number[]
|
||||
}
|
||||
|
||||
export function useUsagePagination(options: UseUsagePaginationOptions) {
|
||||
const {
|
||||
records,
|
||||
initialPage = 1,
|
||||
initialPageSize = 20,
|
||||
pageSizeOptions = [10, 20, 50, 100]
|
||||
} = options
|
||||
|
||||
// 分页状态
|
||||
const currentPage = ref(initialPage)
|
||||
const pageSize = ref(initialPageSize)
|
||||
|
||||
// 计算总页数
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(records.value.length / pageSize.value))
|
||||
)
|
||||
|
||||
// 计算总记录数
|
||||
const totalRecords = computed(() => records.value.length)
|
||||
|
||||
// 分页后的记录
|
||||
const paginatedRecords = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return records.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 处理页码变化
|
||||
function changePage(page: number) {
|
||||
if (page < 1 || page > totalPages.value) return
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
// 处理每页大小变化
|
||||
function changePageSize(size: number) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
}
|
||||
|
||||
// 重置到第一页
|
||||
function resetPage() {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
// 跳转到最后一页
|
||||
function goToLastPage() {
|
||||
currentPage.value = totalPages.value
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
currentPage,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
|
||||
// 计算属性
|
||||
totalPages,
|
||||
totalRecords,
|
||||
paginatedRecords,
|
||||
|
||||
// 方法
|
||||
changePage,
|
||||
changePageSize,
|
||||
resetPage,
|
||||
goToLastPage
|
||||
}
|
||||
}
|
||||
1
frontend/src/features/usage/index.ts
Normal file
1
frontend/src/features/usage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components'
|
||||
120
frontend/src/features/usage/types.ts
Normal file
120
frontend/src/features/usage/types.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
|
||||
// 统计数据状态
|
||||
export interface UsageStatsState {
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
total_actual_cost?: number // 倍率消耗(仅管理员可见)
|
||||
avg_response_time: number
|
||||
error_count?: number
|
||||
error_rate?: number
|
||||
cache_stats?: {
|
||||
cache_creation_tokens: number
|
||||
cache_read_tokens: number
|
||||
cache_creation_cost: number
|
||||
cache_read_cost: number
|
||||
}
|
||||
period_start: string
|
||||
period_end: string
|
||||
activity_heatmap: ActivityHeatmap | null
|
||||
}
|
||||
|
||||
// 模型统计
|
||||
export interface ModelStatsItem {
|
||||
model: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
actual_cost?: number // 倍率消耗
|
||||
}
|
||||
|
||||
// 增强的模型统计(包含效率分析)
|
||||
export interface EnhancedModelStatsItem extends ModelStatsItem {
|
||||
costPerToken: string
|
||||
}
|
||||
|
||||
// 提供商统计
|
||||
export interface ProviderStatsItem {
|
||||
provider: string
|
||||
requests: number
|
||||
totalTokens: number
|
||||
totalCost: number
|
||||
actualCost?: number
|
||||
successRate: number
|
||||
avgResponseTime: string
|
||||
}
|
||||
|
||||
// API格式统计
|
||||
export interface ApiFormatStatsItem {
|
||||
api_format: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
actual_cost?: number
|
||||
avgResponseTime: string
|
||||
}
|
||||
|
||||
// 请求记录
|
||||
// 请求状态类型
|
||||
export type RequestStatus = 'pending' | 'streaming' | 'completed' | 'failed'
|
||||
|
||||
export interface UsageRecord {
|
||||
id: string
|
||||
user_id?: string
|
||||
username?: string
|
||||
user_email?: string
|
||||
provider: string
|
||||
api_key_name?: string
|
||||
rate_multiplier?: number
|
||||
model: string
|
||||
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
|
||||
api_format?: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
total_tokens: number
|
||||
cost: number
|
||||
actual_cost?: number
|
||||
response_time_ms?: number
|
||||
is_stream: boolean
|
||||
status_code?: number
|
||||
error_message?: string
|
||||
status?: RequestStatus // 请求状态: pending, streaming, completed, failed
|
||||
created_at: string
|
||||
has_fallback?: boolean
|
||||
request_metadata?: {
|
||||
model_version?: string // Provider 返回的实际模型版本(如 Gemini 的 modelVersion)
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
// 日期范围参数
|
||||
export interface DateRangeParams {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
// 时间段选项
|
||||
export type PeriodValue = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last90days'
|
||||
|
||||
// 筛选状态(包含新的请求状态值)
|
||||
export type FilterStatusValue = '__all__' | 'stream' | 'standard' | 'error' | 'active' | 'pending' | 'streaming' | 'completed' | 'failed'
|
||||
|
||||
// 默认统计状态
|
||||
export function createDefaultStats(): UsageStatsState {
|
||||
return {
|
||||
total_requests: 0,
|
||||
total_tokens: 0,
|
||||
total_cost: 0,
|
||||
total_actual_cost: undefined,
|
||||
avg_response_time: 0,
|
||||
error_count: undefined,
|
||||
error_rate: undefined,
|
||||
cache_stats: undefined,
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
activity_heatmap: null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user