feat: add provider analytics summary
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { BoltIcon, ClockIcon } from '@heroicons/vue/24/outline'
|
||||
import { api } from '../api/client'
|
||||
import type {
|
||||
@@ -29,12 +29,18 @@ const activeJob = ref<GenerationJobDetail | null>(null)
|
||||
const providerStats = ref<GenerationProviderStats | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const isDark = computed(() => props.tone === 'dark')
|
||||
const latestJob = computed(() => jobHistory.value[0] ?? null)
|
||||
const activeJobEvents = computed(() => activeJob.value?.events.slice(-10) ?? [])
|
||||
const activeProgress = computed(() => activeJob.value?.progress_percent ?? latestJob.value?.progress_percent ?? 0)
|
||||
const activeProgressLabel = computed(() => activeJob.value?.progress_label ?? latestJob.value?.progress_label ?? '暂无进度')
|
||||
const shouldAutoRefresh = computed(() => {
|
||||
if (activeJob.value) return !activeJob.value.is_terminal
|
||||
if (latestJob.value) return !latestJob.value.is_terminal
|
||||
return false
|
||||
})
|
||||
const providerSuccessRate = computed(() => {
|
||||
if (!providerStats.value?.total_calls) return null
|
||||
return Math.round((providerStats.value.successful_calls / providerStats.value.total_calls) * 100)
|
||||
@@ -195,6 +201,13 @@ async function refresh() {
|
||||
}
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.storyId,
|
||||
() => {
|
||||
@@ -203,6 +216,19 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(shouldAutoRefresh, (enabled) => {
|
||||
stopAutoRefresh()
|
||||
if (enabled) {
|
||||
refreshTimer = setInterval(() => {
|
||||
if (!loading.value) {
|
||||
void refresh()
|
||||
}
|
||||
}, 2500)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(stopAutoRefresh)
|
||||
|
||||
defineExpose({ refresh })
|
||||
</script>
|
||||
|
||||
|
||||
@@ -49,3 +49,14 @@ export interface GenerationProviderStats {
|
||||
estimated_cost_usd: number
|
||||
by_provider: GenerationProviderStat[]
|
||||
}
|
||||
|
||||
export interface GenerationProviderAnalytics {
|
||||
total_calls: number
|
||||
successful_calls: number
|
||||
failed_calls: number
|
||||
avg_latency_ms: number | null
|
||||
estimated_cost_usd: number
|
||||
job_count: number
|
||||
story_count: number
|
||||
by_provider: GenerationProviderStat[]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import BaseCard from '../components/ui/BaseCard.vue'
|
||||
import EmptyState from '../components/ui/EmptyState.vue'
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||
import type { GenerationProviderAnalytics } from '../types/generation'
|
||||
import {
|
||||
getAssetStatusMeta,
|
||||
getGenerationStatusMeta,
|
||||
@@ -37,6 +38,7 @@ interface StoryItem {
|
||||
|
||||
const router = useRouter()
|
||||
const stories = ref<StoryItem[]>([])
|
||||
const providerAnalytics = ref<GenerationProviderAnalytics | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const showCreateModal = ref(false)
|
||||
@@ -48,10 +50,22 @@ const readableCount = computed(() =>
|
||||
const attentionCount = computed(() =>
|
||||
stories.value.filter((story) => needsGenerationAttention(story.generation_status)).length,
|
||||
)
|
||||
const providerSuccessRate = computed(() => {
|
||||
if (!providerAnalytics.value?.total_calls) return null
|
||||
return Math.round(
|
||||
(providerAnalytics.value.successful_calls / providerAnalytics.value.total_calls) * 100,
|
||||
)
|
||||
})
|
||||
const topProvider = computed(() => providerAnalytics.value?.by_provider[0] ?? null)
|
||||
|
||||
async function fetchStories() {
|
||||
try {
|
||||
stories.value = await api.get<StoryItem[]>('/api/stories')
|
||||
const [storyList, analytics] = await Promise.all([
|
||||
api.get<StoryItem[]>('/api/stories'),
|
||||
api.get<GenerationProviderAnalytics>('/api/generations/provider-analytics'),
|
||||
])
|
||||
stories.value = storyList
|
||||
providerAnalytics.value = analytics
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
@@ -84,6 +98,14 @@ function getStoryLink(story: StoryItem) {
|
||||
return story.mode === 'storybook' ? `/storybook/view/${story.id}` : `/story/${story.id}`
|
||||
}
|
||||
|
||||
function formatLatency(value?: number | null) {
|
||||
return typeof value === 'number' ? `${Math.round(value)}ms` : '暂无'
|
||||
}
|
||||
|
||||
function formatCost(value?: number | null) {
|
||||
return typeof value === 'number' ? `$${value.toFixed(4)}` : '$0.0000'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void fetchStories()
|
||||
|
||||
@@ -158,6 +180,42 @@ onMounted(() => {
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard
|
||||
v-if="providerAnalytics?.total_calls"
|
||||
class="mb-8"
|
||||
padding="lg"
|
||||
>
|
||||
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-800">Provider 运营摘要</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-gray-500">
|
||||
最近生成和资源补全留下的供应商调用轨迹。
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:min-w-[520px]">
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
|
||||
<div class="text-xs text-gray-500">成功率</div>
|
||||
<div class="mt-1 text-lg font-semibold text-gray-800">{{ providerSuccessRate }}%</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
|
||||
<div class="text-xs text-gray-500">平均耗时</div>
|
||||
<div class="mt-1 text-lg font-semibold text-gray-800">{{ formatLatency(providerAnalytics.avg_latency_ms) }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
|
||||
<div class="text-xs text-gray-500">预估成本</div>
|
||||
<div class="mt-1 text-lg font-semibold text-gray-800">{{ formatCost(providerAnalytics.estimated_cost_usd) }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
|
||||
<div class="text-xs text-gray-500">调用次数</div>
|
||||
<div class="mt-1 text-lg font-semibold text-gray-800">{{ providerAnalytics.total_calls }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="topProvider" class="mt-4 text-sm text-gray-500">
|
||||
当前样本中最前面的能力组合是 {{ topProvider.capability }} / {{ topProvider.adapter }},成功 {{ topProvider.success_count }} 次,失败 {{ topProvider.failure_count }} 次。
|
||||
</p>
|
||||
</BaseCard>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<router-link
|
||||
v-for="story in stories"
|
||||
|
||||
Reference in New Issue
Block a user