feat: add admin provider analytics dashboard

This commit is contained in:
2026-04-19 18:56:17 +08:00
parent b89ca96e4b
commit 395cdf4edd
12 changed files with 886 additions and 51 deletions

View File

@@ -29,6 +29,248 @@
</div>
</header>
<BaseCard padding="lg">
<div class="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
<div class="max-w-2xl">
<div class="flex flex-wrap items-center gap-3">
<h2 class="text-xl font-bold text-gray-900">当前环境 Provider 运营摘要</h2>
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
跨用户 / 当前环境
</span>
</div>
<p class="mt-2 text-sm leading-6 text-gray-500">
这里展示的是当前部署环境内所有生成任务留下的 Provider 调用轨迹便于运营和排障
跨环境对比仍需要后续独立汇聚层
</p>
<div class="mt-4 flex flex-wrap gap-2">
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsWindow === '7' ? 'border-gray-900 bg-gray-900 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="analyticsWindow = '7'"
>
最近 7
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsWindow === '30' ? 'border-gray-900 bg-gray-900 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="analyticsWindow = '30'"
>
最近 30
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsWindow === 'all' ? 'border-gray-900 bg-gray-900 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="analyticsWindow = 'all'"
>
全部
</button>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsCapability === 'all' ? 'border-indigo-600 bg-indigo-600 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="analyticsCapability = 'all'"
>
全部能力
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsCapability === 'text' ? 'border-indigo-600 bg-indigo-600 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="analyticsCapability = 'text'"
>
文本
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsCapability === 'image' ? 'border-indigo-600 bg-indigo-600 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="analyticsCapability = 'image'"
>
图片
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsCapability === 'tts' ? 'border-indigo-600 bg-indigo-600 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="analyticsCapability = 'tts'"
>
语音
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsCapability === 'storybook' ? 'border-indigo-600 bg-indigo-600 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="analyticsCapability = 'storybook'"
>
绘本
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:min-w-[420px]">
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">活跃用户</div>
<div class="mt-1 text-2xl font-semibold text-gray-900">
{{ analytics?.user_count ?? 0 }}
</div>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">总调用</div>
<div class="mt-1 text-2xl font-semibold text-gray-900">
{{ analytics?.total_calls ?? 0 }}
</div>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">成功率</div>
<div class="mt-1 text-2xl font-semibold text-gray-900">
{{ providerSuccessRate ?? '--' }}<span v-if="providerSuccessRate !== null">%</span>
</div>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">预估成本</div>
<div class="mt-1 text-2xl font-semibold text-gray-900">
{{ formatCost(analytics?.estimated_cost_usd) }}
</div>
</div>
</div>
</div>
<div v-if="analyticsLoading" class="mt-5 rounded-xl border border-dashed border-gray-200 px-4 py-6 text-sm text-gray-500">
正在更新运营摘要...
</div>
<div v-else-if="analyticsError" class="mt-5 rounded-xl border border-rose-100 bg-rose-50 px-4 py-4 text-sm text-rose-600">
{{ analyticsError }}
</div>
<template v-else-if="analytics">
<div class="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="rounded-xl border border-gray-100 bg-white px-4 py-3">
<div class="text-xs text-gray-500">覆盖故事</div>
<div class="mt-1 text-lg font-semibold text-gray-900">{{ analytics.story_count }}</div>
</div>
<div class="rounded-xl border border-gray-100 bg-white px-4 py-3">
<div class="text-xs text-gray-500">覆盖任务</div>
<div class="mt-1 text-lg font-semibold text-gray-900">{{ analytics.job_count }}</div>
</div>
<div class="rounded-xl border border-gray-100 bg-white px-4 py-3">
<div class="text-xs text-gray-500">平均耗时</div>
<div class="mt-1 text-lg font-semibold text-gray-900">{{ formatLatency(analytics.avg_latency_ms) }}</div>
</div>
<div class="rounded-xl border border-gray-100 bg-white px-4 py-3">
<div class="text-xs text-gray-500">配置中 Provider</div>
<div class="mt-1 text-lg font-semibold text-gray-900">{{ enabledProviderCount }}/{{ providers.length }}</div>
</div>
</div>
<div class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<div class="rounded-2xl border border-gray-100 bg-white">
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-4">
<div>
<h3 class="font-semibold text-gray-900">Provider 调用明细</h3>
<p class="mt-1 text-xs text-gray-500">按能力和 adapter 聚合的当前环境视图</p>
</div>
<span class="text-xs text-gray-400">{{ analyticsProviderRows.length }} 个组合</span>
</div>
<div class="divide-y divide-gray-100">
<div
v-for="row in analyticsProviderRows"
:key="`${row.capability}:${row.adapter}`"
class="grid grid-cols-1 gap-3 px-5 py-4 md:grid-cols-[minmax(0,1.2fr)_repeat(4,minmax(0,0.8fr))]"
>
<div>
<div class="flex items-center gap-2">
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600">
{{ formatCapability(row.capability) }}
</span>
<span class="font-medium text-gray-900">{{ row.adapter }}</span>
</div>
</div>
<div>
<div class="text-[11px] text-gray-400">调用</div>
<div class="mt-1 text-sm font-medium text-gray-900">{{ row.call_count }}</div>
</div>
<div>
<div class="text-[11px] text-gray-400">成功率</div>
<div class="mt-1 text-sm font-medium text-gray-900">
{{ getSuccessRate(row.success_count, row.call_count) ?? '--' }}<span v-if="getSuccessRate(row.success_count, row.call_count) !== null">%</span>
</div>
</div>
<div>
<div class="text-[11px] text-gray-400">耗时</div>
<div class="mt-1 text-sm font-medium text-gray-900">{{ formatLatency(row.avg_latency_ms) }}</div>
</div>
<div>
<div class="text-[11px] text-gray-400">成本</div>
<div class="mt-1 text-sm font-medium text-gray-900">{{ formatCost(row.estimated_cost_usd) }}</div>
</div>
</div>
<div v-if="analyticsProviderRows.length === 0" class="px-5 py-8 text-sm text-gray-500">
当前筛选条件下还没有 Provider 调用样本
</div>
</div>
</div>
<div class="space-y-6">
<div class="rounded-2xl border border-gray-100 bg-white">
<div class="border-b border-gray-100 px-5 py-4">
<h3 class="font-semibold text-gray-900">跨用户分布</h3>
<p class="mt-1 text-xs text-gray-500">按调用量排序帮助快速识别主要使用者</p>
</div>
<div class="divide-y divide-gray-100">
<div
v-for="row in analyticsUserRows.slice(0, 5)"
:key="row.user_id"
class="px-5 py-4"
>
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900">{{ row.user_id }}</div>
<div class="mt-1 text-xs text-gray-500">
{{ row.story_count }} 个故事 · {{ row.job_count }} 个任务
</div>
</div>
<div class="text-right">
<div class="text-sm font-semibold text-gray-900">{{ row.call_count }} </div>
<div class="mt-1 text-xs text-gray-500">{{ formatCost(row.estimated_cost_usd) }}</div>
</div>
</div>
</div>
<div v-if="analyticsUserRows.length === 0" class="px-5 py-8 text-sm text-gray-500">
当前还没有跨用户样本
</div>
</div>
</div>
<div class="rounded-2xl border border-gray-100 bg-white">
<div class="border-b border-gray-100 px-5 py-4">
<h3 class="font-semibold text-gray-900">最近失败原因</h3>
<p class="mt-1 text-xs text-gray-500">先看最常见原因再决定是否调整路由或优先级</p>
</div>
<div class="divide-y divide-gray-100">
<div
v-for="reason in analytics.failure_reasons.slice(0, 5)"
:key="reason.reason"
class="flex items-center justify-between gap-3 px-5 py-4"
>
<span class="text-sm text-gray-700">{{ reason.reason }}</span>
<span class="rounded-full bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-600">
{{ reason.count }}
</span>
</div>
<div v-if="analytics.failure_reasons.length === 0" class="px-5 py-8 text-sm text-gray-500">
当前窗口内还没有失败调用
</div>
</div>
</div>
</div>
</div>
</template>
</BaseCard>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
@@ -275,7 +517,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import BaseButton from '../components/ui/BaseButton.vue'
import BaseCard from '../components/ui/BaseCard.vue'
import BaseInput from '../components/ui/BaseInput.vue'
@@ -299,7 +541,46 @@ type Provider = {
config_json: Record<string, any>
}
// State
type ProviderAnalyticsBucket = {
capability: string
adapter: string
call_count: number
success_count: number
failure_count: number
avg_latency_ms: number | null
estimated_cost_usd: number
}
type ProviderAnalyticsUserBucket = {
user_id: string
call_count: number
success_count: number
failure_count: number
job_count: number
story_count: number
estimated_cost_usd: number
}
type ProviderAnalyticsResponse = {
scope: string
window_days: number | null
capability: string | null
total_calls: number
successful_calls: number
failed_calls: number
avg_latency_ms: number | null
estimated_cost_usd: number
user_count: number
job_count: number
story_count: number
by_provider: ProviderAnalyticsBucket[]
by_user: ProviderAnalyticsUserBucket[]
failure_reasons: Array<{
reason: string
count: number
}>
}
// State
const loginForm = ref({ username: '', password: '' })
const loginError = ref('')
@@ -308,6 +589,11 @@ const activeTab = ref('text')
const providers = ref<Provider[]>([])
const defaults = ref<Record<string, string[]>>({})
const availableAdapters = ref<string[]>([])
const analytics = ref<ProviderAnalyticsResponse | null>(null)
const analyticsLoading = ref(false)
const analyticsError = ref('')
const analyticsWindow = ref<'7' | '30' | 'all'>('30')
const analyticsCapability = ref<'all' | 'text' | 'image' | 'tts' | 'storybook'>('all')
const editing = ref(false)
const form = ref<Partial<Provider> & { api_key?: string; config_json: Record<string, any> }>({
type: 'text',
@@ -322,6 +608,46 @@ function getAuthHeader(): string {
return sessionStorage.getItem('admin_auth') || ''
}
function buildAnalyticsPath() {
const params = new URLSearchParams()
if (analyticsWindow.value !== 'all') {
params.set('days', analyticsWindow.value)
}
if (analyticsCapability.value !== 'all') {
params.set('capability', analyticsCapability.value)
}
const query = params.toString()
return `${apiBase}/admin/providers/analytics${query ? `?${query}` : ''}`
}
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'
}
function formatCapability(value: string) {
switch (value) {
case 'text':
return '文本'
case 'image':
return '图片'
case 'tts':
return '语音'
case 'storybook':
return '绘本'
default:
return value
}
}
function getSuccessRate(successCount: number, totalCount: number) {
if (!totalCount) return null
return Math.round((successCount / totalCount) * 100)
}
// Actions
async function login() {
loginError.value = ''
@@ -349,24 +675,58 @@ function logout() {
sessionStorage.removeItem('admin_auth')
isLoggedIn.value = false
providers.value = []
analytics.value = null
window.location.reload()
}
async function loadData() {
if (!isLoggedIn.value) return
const headers = { Authorization: getAuthHeader() }
// Parallel fetch
const [pRes, dRes, aRes] = await Promise.all([
fetch(`${apiBase}/admin/providers`, { headers }),
fetch(`${apiBase}/admin/providers/defaults`, { headers }),
fetch(`${apiBase}/admin/providers/adapters`, { headers })
])
async function loadAnalytics() {
if (!isLoggedIn.value) return
if (pRes.ok) providers.value = await pRes.json()
if (dRes.ok) defaults.value = await dRes.json()
if (aRes.ok) availableAdapters.value = await aRes.json()
analyticsLoading.value = true
analyticsError.value = ''
try {
const response = await fetch(buildAnalyticsPath(), {
headers: { Authorization: getAuthHeader() },
})
if (!response.ok) {
throw new Error('运营数据加载失败')
}
analytics.value = await response.json()
} catch (error) {
analyticsError.value = error instanceof Error ? error.message : '运营数据加载失败'
analytics.value = null
} finally {
analyticsLoading.value = false
}
}
async function loadData() {
if (!isLoggedIn.value) return
const headers = { Authorization: getAuthHeader() }
analyticsLoading.value = true
try {
const [pRes, dRes, aRes, analyticsRes] = await Promise.all([
fetch(`${apiBase}/admin/providers`, { headers }),
fetch(`${apiBase}/admin/providers/defaults`, { headers }),
fetch(`${apiBase}/admin/providers/adapters`, { headers }),
fetch(buildAnalyticsPath(), { headers }),
])
if (pRes.ok) providers.value = await pRes.json()
if (dRes.ok) defaults.value = await dRes.json()
if (aRes.ok) availableAdapters.value = await aRes.json()
if (analyticsRes.ok) {
analytics.value = await analyticsRes.json()
analyticsError.value = ''
} else {
analytics.value = null
analyticsError.value = '运营数据加载失败'
}
} finally {
analyticsLoading.value = false
}
}
// Computed
@@ -385,6 +745,37 @@ const adapterOptions = computed(() => {
})
})
const enabledProviderCount = computed(() => providers.value.filter((provider) => provider.enabled).length)
const providerSuccessRate = computed(() => {
if (!analytics.value?.total_calls) return null
return getSuccessRate(analytics.value.successful_calls, analytics.value.total_calls)
})
const analyticsProviderRows = computed(() => {
return [...(analytics.value?.by_provider || [])].sort((left, right) => {
if (right.call_count !== left.call_count) {
return right.call_count - left.call_count
}
if (right.estimated_cost_usd !== left.estimated_cost_usd) {
return right.estimated_cost_usd - left.estimated_cost_usd
}
return left.adapter.localeCompare(right.adapter)
})
})
const analyticsUserRows = computed(() => {
return [...(analytics.value?.by_user || [])].sort((left, right) => {
if (right.call_count !== left.call_count) {
return right.call_count - left.call_count
}
if (right.estimated_cost_usd !== left.estimated_cost_usd) {
return right.estimated_cost_usd - left.estimated_cost_usd
}
return left.user_id.localeCompare(right.user_id)
})
})
// UI Actions
function cloneDefault(type: string, name: string) {
// 根据默认名称推断配置
@@ -431,28 +822,28 @@ function reset() {
async function submit() {
const method = form.value.id ? 'PUT' : 'POST'
const url = form.value.id
const url = form.value.id
? `${apiBase}/admin/providers/${form.value.id}`
: `${apiBase}/admin/providers`
await fetch(url, {
method,
headers: {
headers: {
'Content-Type': 'application/json',
Authorization: getAuthHeader()
Authorization: getAuthHeader(),
},
body: JSON.stringify(form.value)
body: JSON.stringify(form.value),
})
await loadData()
reset()
}
async function remove(p: Provider) {
if(!confirm(`确认删除 ${p.name}?`)) return
if (!confirm(`确认删除 ${p.name}?`)) return
await fetch(`${apiBase}/admin/providers/${p.id}`, {
method: 'DELETE',
headers: { Authorization: getAuthHeader() }
headers: { Authorization: getAuthHeader() },
})
await loadData()
}
@@ -460,11 +851,11 @@ async function remove(p: Provider) {
async function toggleEnabled(p: Provider) {
await fetch(`${apiBase}/admin/providers/${p.id}`, {
method: 'PUT',
headers: {
headers: {
'Content-Type': 'application/json',
Authorization: getAuthHeader()
Authorization: getAuthHeader(),
},
body: JSON.stringify({ enabled: !p.enabled })
body: JSON.stringify({ enabled: !p.enabled }),
})
await loadData()
}
@@ -472,4 +863,10 @@ async function toggleEnabled(p: Provider) {
onMounted(() => {
if (isLoggedIn.value) loadData()
})
watch([analyticsWindow, analyticsCapability], () => {
if (isLoggedIn.value) {
void loadAnalytics()
}
})
</script>