mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
feat: revamp analytics dashboards and harden database migrations
Add dashboard and overview analytics, health monitoring, provider expense tracking, and announcement updates across the gateway and frontend. Keep schema migrations free of historical backfills while preserving automatic backfill execution. Bound migration deadlines, run schema preparation before Compose replacement, and anonymize deleted dashboard users. Include the current documentation cleanup and regression coverage.
This commit is contained in:
@@ -0,0 +1,620 @@
|
||||
<template>
|
||||
<div class="relative min-w-0 max-w-full space-y-5 pb-8">
|
||||
<header class="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold">
|
||||
{{ publicPage ? '服务状态' : '健康监控' }}
|
||||
</h1>
|
||||
<p
|
||||
v-if="meta"
|
||||
class="mt-2 text-xs text-muted-foreground"
|
||||
>
|
||||
更新于 {{ dateTime(meta.generated_at) }}
|
||||
<span
|
||||
v-if="meta.freshness === 'stale'"
|
||||
class="ml-2 text-amber-600"
|
||||
>数据已过期</span>
|
||||
<span
|
||||
v-else-if="meta.freshness === 'unknown'"
|
||||
class="ml-2"
|
||||
>采集进度未知</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label
|
||||
class="sr-only"
|
||||
for="health-window"
|
||||
>时间范围</label>
|
||||
<select
|
||||
id="health-window"
|
||||
:value="window"
|
||||
class="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
@change="changeWindow"
|
||||
>
|
||||
<option value="1h">
|
||||
近 1 小时
|
||||
</option><option value="6h">
|
||||
近 6 小时
|
||||
</option><option value="24h">
|
||||
近 24 小时
|
||||
</option><option value="72h">
|
||||
近 72 小时
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
type="button"
|
||||
class="flex h-9 items-center gap-2 rounded-md border border-input px-3 text-sm hover:bg-muted"
|
||||
@click="openPublication"
|
||||
>
|
||||
<Globe class="h-4 w-4" />状态页发布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="刷新"
|
||||
aria-label="刷新健康数据"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md border border-input hover:bg-muted disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
@click="load"
|
||||
>
|
||||
<RefreshCw
|
||||
class="h-4 w-4"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<nav
|
||||
class="flex gap-5 overflow-x-auto border-b border-border"
|
||||
aria-label="健康对象"
|
||||
>
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.kind"
|
||||
type="button"
|
||||
class="shrink-0 border-b-2 px-1 pb-3 text-sm"
|
||||
:class="kind === tab.kind ? 'border-primary font-medium text-foreground' : 'border-transparent text-muted-foreground'"
|
||||
:aria-current="kind === tab.kind ? 'page' : undefined"
|
||||
@click="setQuery({ kind: tab.kind, offset: undefined, object: undefined }, true)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
<div
|
||||
v-if="error"
|
||||
role="alert"
|
||||
class="flex items-start gap-2 border-l-2 border-destructive bg-destructive/5 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0" />{{ error }}
|
||||
</div>
|
||||
<section
|
||||
v-if="summary"
|
||||
class="grid grid-cols-2 gap-y-5 border-b border-border pb-5 md:grid-cols-4"
|
||||
aria-label="健康摘要"
|
||||
>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
总体状态
|
||||
</div><div class="mt-2 flex items-center gap-2 text-lg font-semibold">
|
||||
<span
|
||||
class="h-2.5 w-2.5 rounded-full"
|
||||
:class="statusColor(summary.status)"
|
||||
/>{{ statusLabel(summary.status) }}
|
||||
</div><div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ summary.object_count }} 个对象
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
服务可用率
|
||||
</div><div class="mt-2 text-lg font-semibold tabular-nums">
|
||||
{{ percentage(summary.requests.service_availability.value) }}
|
||||
</div><div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ summary.requests.service_availability.denominator.toLocaleString() }} 个有效样本
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
异常对象
|
||||
</div><div class="mt-2 text-lg font-semibold tabular-nums">
|
||||
{{ summary.degraded_count + summary.unavailable_count }}
|
||||
</div><div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ summary.unknown_count }} 个状态未知
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
最终请求成功率
|
||||
</div><div class="mt-2 text-lg font-semibold tabular-nums">
|
||||
{{ percentage(summary.requests.request_success.value) }}
|
||||
</div><div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ summary.requests.request_count.toLocaleString() }} 次请求
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
v-if="loading && !page"
|
||||
role="status"
|
||||
class="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<LoaderCircle class="h-4 w-4 animate-spin" />加载中
|
||||
</div>
|
||||
<template v-else-if="page">
|
||||
<div
|
||||
v-if="page.items.length === 0"
|
||||
class="border-y border-dashed border-border py-16 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ publicPage ? '此视角暂无公开服务' : '此视角暂无监控对象' }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="relative max-w-full overflow-x-auto"
|
||||
>
|
||||
<table class="health-object-table w-full min-w-[740px] text-left text-sm">
|
||||
<thead class="border-b border-border text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="py-3 pr-4 font-medium">
|
||||
{{ activeLabel }}
|
||||
</th><th class="px-3 py-3 font-medium">
|
||||
状态
|
||||
</th><th class="px-3 py-3 text-right font-medium">
|
||||
服务可用率
|
||||
</th><th class="px-3 py-3 text-right font-medium">
|
||||
请求数
|
||||
</th><th class="px-3 py-3 text-right font-medium">
|
||||
平均耗时
|
||||
</th><th class="px-3 py-3 font-medium">
|
||||
窗口状态
|
||||
</th><th class="w-10">
|
||||
<span class="sr-only">详情</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
<tr
|
||||
v-for="object in page.items"
|
||||
:key="object.id"
|
||||
class="hover:bg-muted/40"
|
||||
>
|
||||
<td class="max-w-64 py-4 pr-4">
|
||||
<button
|
||||
class="break-words text-left font-medium hover:text-primary"
|
||||
type="button"
|
||||
@click="selectObject(object.id)"
|
||||
>
|
||||
{{ object.name }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4">
|
||||
<span class="inline-flex items-center gap-2"><span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="statusColor(object.status)"
|
||||
/>{{ statusLabel(object.status) }}</span><div
|
||||
v-if="object.coverage.sample_status !== 'sufficient'"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ object.coverage.sample_status === 'empty' ? '暂无样本' : '样本不足' }}
|
||||
</div><div
|
||||
v-else-if="object.coverage.status === 'partial'"
|
||||
class="mt-1 text-xs text-amber-600"
|
||||
>
|
||||
分类不完整
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-4 text-right tabular-nums">
|
||||
{{ percentage(object.service_availability.value) }}
|
||||
</td><td class="px-3 py-4 text-right tabular-nums">
|
||||
{{ object.request_count.toLocaleString() }}
|
||||
</td><td class="whitespace-nowrap px-3 py-4 text-right tabular-nums">
|
||||
{{ object.average_latency_ms == null ? '未知' : `${Math.round(object.average_latency_ms).toLocaleString()} ms` }}
|
||||
</td>
|
||||
<td class="px-3 py-4">
|
||||
<div class="flex h-7 min-w-36 gap-0.5">
|
||||
<span
|
||||
v-for="(bucket, index) in object.timeline"
|
||||
:key="index"
|
||||
class="min-w-1 flex-1 rounded-sm"
|
||||
:class="statusColor(bucket.status)"
|
||||
:title="`${dateTime(bucket.from)} - ${dateTime(bucket.to)}: ${statusLabel(bucket.status)} (${bucket.service_availability.denominator} 个样本)`"
|
||||
/><span
|
||||
v-if="object.timeline.length === 0"
|
||||
class="flex-1 rounded-sm bg-muted"
|
||||
title="暂无样本"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="pl-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted"
|
||||
:aria-label="`查看 ${object.name}`"
|
||||
title="查看详情"
|
||||
@click="selectObject(object.id)"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-t border-border pt-4 text-xs text-muted-foreground">
|
||||
<span>共 {{ page.total }} 个对象</span><div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
title="上一页"
|
||||
aria-label="上一页"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md border border-input disabled:opacity-30"
|
||||
:disabled="offset === 0 || loading"
|
||||
@click="setQuery({ offset: String(Math.max(0, offset - 25)) })"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button><span>{{ Math.floor(offset / 25) + 1 }} / {{ Math.max(1, Math.ceil(page.total / 25)) }}</span><button
|
||||
type="button"
|
||||
title="下一页"
|
||||
aria-label="下一页"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md border border-input disabled:opacity-30"
|
||||
:disabled="offset + 25 >= page.total || loading"
|
||||
@click="setQuery({ offset: String(offset + 25) })"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<Dialog
|
||||
:open="Boolean(selectedId)"
|
||||
:title="detail?.name || '健康详情'"
|
||||
max-width="2xl"
|
||||
@update:open="closeDetail"
|
||||
>
|
||||
<div
|
||||
v-if="detailLoading"
|
||||
role="status"
|
||||
class="py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
加载中
|
||||
</div>
|
||||
<div
|
||||
v-else-if="detailError"
|
||||
role="alert"
|
||||
class="text-sm text-destructive"
|
||||
>
|
||||
{{ detailError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="detail"
|
||||
class="space-y-5"
|
||||
>
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<span
|
||||
class="h-2.5 w-2.5 rounded-full"
|
||||
:class="statusColor(detail.status)"
|
||||
/>{{ statusLabel(detail.status) }}
|
||||
</div>
|
||||
<dl class="grid grid-cols-2 gap-x-6 gap-y-4 text-sm">
|
||||
<div>
|
||||
<dt class="text-muted-foreground">
|
||||
服务可用率
|
||||
</dt><dd class="mt-1 font-medium">
|
||||
{{ percentage(detail.service_availability.value) }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-muted-foreground">
|
||||
最终请求成功率
|
||||
</dt><dd class="mt-1 font-medium">
|
||||
{{ percentage(detail.request_success.value) }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-muted-foreground">
|
||||
有效服务样本
|
||||
</dt><dd class="mt-1">
|
||||
{{ detail.service_availability.denominator.toLocaleString() }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-muted-foreground">
|
||||
已排除请求
|
||||
</dt><dd class="mt-1">
|
||||
{{ detail.coverage.excluded_count.toLocaleString() }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-muted-foreground">
|
||||
未分类失败
|
||||
</dt><dd class="mt-1">
|
||||
{{ detail.coverage.unknown_failure_count.toLocaleString() }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-muted-foreground">
|
||||
最后请求
|
||||
</dt><dd class="mt-1">
|
||||
{{ detail.last_request_at ? dateTime(detail.last_request_at) : '无流量' }}
|
||||
</dd>
|
||||
</div><template v-if="adminDetail">
|
||||
<div>
|
||||
<dt class="text-muted-foreground">
|
||||
渠道尝试成功率
|
||||
</dt><dd class="mt-1">
|
||||
{{ percentage(adminDetail.attempts.success.value) }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-muted-foreground">
|
||||
执行中渠道尝试
|
||||
</dt><dd class="mt-1">
|
||||
{{ adminDetail.attempts.in_progress_count }}
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
</dl>
|
||||
<div class="flex h-10 gap-1">
|
||||
<span
|
||||
v-for="(bucket, index) in detail.timeline"
|
||||
:key="index"
|
||||
class="min-w-1 flex-1 rounded-sm"
|
||||
:class="statusColor(bucket.status)"
|
||||
:title="`${dateTime(bucket.from)}: ${percentage(bucket.service_availability.value)}`"
|
||||
/>
|
||||
</div>
|
||||
<RouterLink
|
||||
v-if="adminDetail"
|
||||
:to="usageLink"
|
||||
class="inline-flex items-center gap-2 text-sm font-medium text-primary"
|
||||
>
|
||||
使用记录<ArrowUpRight class="h-4 w-4" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
v-model:open="publicationOpen"
|
||||
title="状态页发布(/status)"
|
||||
max-width="3xl"
|
||||
>
|
||||
<div
|
||||
v-if="publicationLoading"
|
||||
class="py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
加载中
|
||||
</div>
|
||||
<div
|
||||
v-else-if="publication"
|
||||
class="space-y-4"
|
||||
>
|
||||
<label class="flex items-center gap-2 text-sm font-medium"><input
|
||||
v-model="publication.enabled"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 accent-primary"
|
||||
>启用 /status 状态页</label>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[560px] text-left text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-border text-muted-foreground">
|
||||
<th class="pb-2 font-medium">
|
||||
类型
|
||||
</th><th class="pb-2 font-medium">
|
||||
监控对象
|
||||
</th><th class="pb-2 font-medium">
|
||||
公开 ID
|
||||
</th><th class="pb-2 font-medium">
|
||||
公开名称
|
||||
</th><th />
|
||||
</tr>
|
||||
</thead><tbody>
|
||||
<tr
|
||||
v-for="(object, index) in publication.objects"
|
||||
:key="index"
|
||||
>
|
||||
<td class="py-2 pr-2">
|
||||
<select
|
||||
v-model="object.kind"
|
||||
class="h-8 w-24 rounded-md border border-input bg-background px-2"
|
||||
>
|
||||
<option value="api_format">
|
||||
API 格式
|
||||
</option><option value="model">
|
||||
模型
|
||||
</option>
|
||||
</select>
|
||||
</td><td class="pr-2">
|
||||
<input
|
||||
v-model="object.value"
|
||||
aria-label="监控对象"
|
||||
class="h-8 w-full rounded-md border border-input bg-background px-2"
|
||||
maxlength="256"
|
||||
>
|
||||
</td><td class="pr-2">
|
||||
<input
|
||||
v-model="object.public_id"
|
||||
aria-label="公开 ID"
|
||||
class="h-8 w-full rounded-md border border-input bg-background px-2"
|
||||
maxlength="80"
|
||||
>
|
||||
</td><td class="pr-2">
|
||||
<input
|
||||
v-model="object.display_name"
|
||||
aria-label="公开名称"
|
||||
class="h-8 w-full rounded-md border border-input bg-background px-2"
|
||||
maxlength="120"
|
||||
>
|
||||
</td><td>
|
||||
<button
|
||||
type="button"
|
||||
title="移除公开对象"
|
||||
aria-label="移除公开对象"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted"
|
||||
@click="publication.objects.splice(index, 1)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 text-sm text-primary"
|
||||
@click="publication.objects.push({ public_id: '', kind: 'api_format', value: '', display_name: '' })"
|
||||
>
|
||||
<Plus class="h-4 w-4" />添加对象
|
||||
</button>
|
||||
<div
|
||||
v-if="publicationError"
|
||||
role="alert"
|
||||
class="text-sm text-destructive"
|
||||
>
|
||||
{{ publicationError }}
|
||||
</div>
|
||||
<div class="flex justify-between border-t border-border pt-4">
|
||||
<RouterLink
|
||||
to="/status"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
公开状态页<ArrowUpRight class="h-4 w-4" />
|
||||
</RouterLink><button
|
||||
type="button"
|
||||
class="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm text-primary-foreground disabled:opacity-50"
|
||||
:disabled="savingPublication"
|
||||
@click="savePublication"
|
||||
>
|
||||
<Save class="h-4 w-4" />保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="publicationError"
|
||||
role="alert"
|
||||
class="text-sm text-destructive"
|
||||
>
|
||||
{{ publicationError }}
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { AlertTriangle, ArrowUpRight, ChevronLeft, ChevronRight, Globe, LoaderCircle, Plus, RefreshCw, Save, Trash2 } from 'lucide-vue-next'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import {
|
||||
getAdminHealthObject, getAdminHealthObjects, getAdminHealthSummary, getHealthPublication,
|
||||
getPublicHealthObject, getPublicHealthObjects, getPublicHealthSummaryV2, saveHealthPublication,
|
||||
getUserHealthObject, getUserHealthObjects, getUserHealthSummary,
|
||||
type AdminHealthObject, type HealthMeta, type HealthObjectKind, type HealthObjectsPage,
|
||||
type HealthPublication, type HealthQuery, type HealthSummaryV2, type HealthWindow,
|
||||
type PublicHealthObject, type PublicHealthQuery, type ServiceHealthStatus,
|
||||
} from '@/api/endpoints/health-v2'
|
||||
|
||||
const props = withDefaults(defineProps<{ isAdmin?: boolean; publicPage?: boolean }>(), { isAdmin: false, publicPage: false })
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tabs = computed(() => [
|
||||
{ kind: 'api_format' as const, label: 'API 格式' },
|
||||
{ kind: 'model' as const, label: '模型' },
|
||||
...(props.isAdmin ? [{ kind: 'provider' as const, label: '提供商' }] : []),
|
||||
])
|
||||
const kind = computed<HealthObjectKind>(() => tabs.value.find(tab => tab.kind === route.query.kind)?.kind ?? 'api_format')
|
||||
const window = computed<HealthWindow>(() => ['1h', '6h', '24h', '72h'].includes(String(route.query.window)) ? route.query.window as HealthWindow : '6h')
|
||||
const offset = computed(() => { const value = Number(route.query.offset); return Number.isInteger(value) && value >= 0 && value <= 10_000 ? value : 0 })
|
||||
const selectedId = computed(() => typeof route.query.object === 'string' ? route.query.object : '')
|
||||
const activeLabel = computed(() => tabs.value.find(tab => tab.kind === kind.value)?.label)
|
||||
const summary = ref<HealthSummaryV2 | null>(null)
|
||||
const page = ref<HealthObjectsPage<PublicHealthObject | AdminHealthObject> | null>(null)
|
||||
const meta = ref<HealthMeta | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const detail = ref<PublicHealthObject | AdminHealthObject | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref('')
|
||||
const adminDetail = computed(() => detail.value && 'attempts' in detail.value ? detail.value as AdminHealthObject : null)
|
||||
let controller: AbortController | null = null
|
||||
let detailController: AbortController | null = null
|
||||
let loadVersion = 0
|
||||
let detailVersion = 0
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const query = computed<HealthQuery>(() => ({ kind: kind.value, window: window.value, limit: 25, offset: offset.value }))
|
||||
function setQuery(patch: Record<string, string | undefined>, push = false) { void router[push ? 'push' : 'replace']({ query: { ...route.query, ...patch } }) }
|
||||
function changeWindow(event: Event) { setQuery({ window: (event.target as HTMLSelectElement).value, offset: undefined, object: undefined }) }
|
||||
function selectObject(id: string) { setQuery({ object: id }, true) }
|
||||
function closeDetail(open: boolean) { if (!open) setQuery({ object: undefined }) }
|
||||
function percentage(value: number | null) { return value == null ? '未知' : `${(value * 100).toFixed(2)}%` }
|
||||
function dateTime(value: string) { return new Date(value).toLocaleString(undefined, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }
|
||||
function statusLabel(status: ServiceHealthStatus) { return { healthy: '正常', degraded: '波动', unavailable: '异常', unknown: '未知' }[status] }
|
||||
function statusColor(status: ServiceHealthStatus) { return { healthy: 'bg-emerald-500', degraded: 'bg-amber-500', unavailable: 'bg-red-500', unknown: 'bg-muted-foreground/25' }[status] }
|
||||
async function load() {
|
||||
const version = ++loadVersion
|
||||
controller?.abort()
|
||||
clearTimeout(refreshTimer)
|
||||
controller = new AbortController()
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [nextSummary, nextPage] = props.isAdmin
|
||||
? await Promise.all([getAdminHealthSummary(query.value, controller.signal), getAdminHealthObjects(query.value, controller.signal)])
|
||||
: props.publicPage
|
||||
? await Promise.all([getPublicHealthSummaryV2(query.value as PublicHealthQuery, controller.signal), getPublicHealthObjects(query.value as PublicHealthQuery, controller.signal)])
|
||||
: await Promise.all([getUserHealthSummary(query.value as PublicHealthQuery, controller.signal), getUserHealthObjects(query.value as PublicHealthQuery, controller.signal)])
|
||||
if (version !== loadVersion) return
|
||||
summary.value = nextSummary.data
|
||||
page.value = nextPage.data
|
||||
meta.value = nextSummary.meta
|
||||
} catch (cause) {
|
||||
if (version !== loadVersion || controller.signal.aborted) return
|
||||
const status = (cause as { response?: { status?: number } }).response?.status
|
||||
error.value = status === 404 && props.publicPage ? '公开状态页尚未启用' : '健康数据暂时不可用,请稍后刷新。'
|
||||
} finally { if (version === loadVersion) { loading.value = false; refreshTimer = setTimeout(load, 60_000) } }
|
||||
}
|
||||
async function loadDetail() {
|
||||
const version = ++detailVersion
|
||||
detailController?.abort()
|
||||
detail.value = null
|
||||
detailError.value = ''
|
||||
if (!selectedId.value) { detailLoading.value = false; return }
|
||||
detailController = new AbortController()
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const result = props.isAdmin
|
||||
? await getAdminHealthObject(selectedId.value, query.value, detailController.signal)
|
||||
: props.publicPage
|
||||
? await getPublicHealthObject(selectedId.value, query.value as PublicHealthQuery, detailController.signal)
|
||||
: await getUserHealthObject(selectedId.value, query.value as PublicHealthQuery, detailController.signal)
|
||||
if (version === detailVersion) detail.value = result.data
|
||||
} catch { if (version === detailVersion && !detailController.signal.aborted) detailError.value = '对象详情暂时不可用' }
|
||||
finally { if (version === detailVersion) detailLoading.value = false }
|
||||
}
|
||||
const usageLink = computed(() => ({ path: '/admin/usage', query: {
|
||||
from: meta.value?.range.from, to: meta.value?.range.to, timezone: 'UTC',
|
||||
[kind.value === 'model' ? 'model' : kind.value === 'provider' ? 'provider_id' : 'api_format']: adminDetail.value?.source_value,
|
||||
} }))
|
||||
const publicationOpen = ref(false)
|
||||
const publicationLoading = ref(false)
|
||||
const publication = ref<HealthPublication | null>(null)
|
||||
const publicationError = ref('')
|
||||
const savingPublication = ref(false)
|
||||
async function openPublication() {
|
||||
publicationOpen.value = true; publicationLoading.value = true; publicationError.value = ''
|
||||
try { publication.value = await getHealthPublication() }
|
||||
catch { publicationError.value = '状态页发布配置暂时不可用' }
|
||||
finally { publicationLoading.value = false }
|
||||
}
|
||||
async function savePublication() {
|
||||
if (!publication.value) return
|
||||
savingPublication.value = true; publicationError.value = ''
|
||||
try { publication.value = await saveHealthPublication(publication.value); publicationOpen.value = false }
|
||||
catch (cause) { publicationError.value = (cause as { response?: { data?: { detail?: string } } }).response?.data?.detail || '保存失败,请稍后重试' }
|
||||
finally { savingPublication.value = false }
|
||||
}
|
||||
watch([kind, window, offset, () => props.isAdmin, () => props.publicPage], () => { summary.value = null; page.value = null; void load() }, { immediate: true })
|
||||
watch([selectedId, kind, window], loadDetail, { immediate: true })
|
||||
onBeforeUnmount(() => { ++loadVersion; ++detailVersion; controller?.abort(); detailController?.abort(); clearTimeout(refreshTimer) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.health-object-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.health-object-table td:first-child {
|
||||
min-width: 160px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
|
||||
import { createMemoryHistory, createRouter, type Router } from 'vue-router'
|
||||
import HealthMonitorView from '../HealthMonitorView.vue'
|
||||
import type { HealthMeta, PublicHealthObject } from '@/api/endpoints/health-v2'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getAdminHealthSummary: vi.fn(), getAdminHealthObjects: vi.fn(), getAdminHealthObject: vi.fn(),
|
||||
getPublicHealthSummaryV2: vi.fn(), getPublicHealthObjects: vi.fn(), getPublicHealthObject: vi.fn(),
|
||||
getUserHealthSummary: vi.fn(), getUserHealthObjects: vi.fn(), getUserHealthObject: vi.fn(),
|
||||
getHealthPublication: vi.fn(), saveHealthPublication: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/api/endpoints/health-v2', () => api)
|
||||
vi.mock('@/components/ui/dialog/Dialog.vue', () => ({
|
||||
default: defineComponent({ props: { open: Boolean, title: String }, setup: (props, { slots }) => () => props.open ? h('section', [h('h2', props.title), slots.default?.()]) : null }),
|
||||
}))
|
||||
|
||||
const ratio = { numerator: 100, denominator: 100, value: 1 }
|
||||
const meta: HealthMeta = {
|
||||
schema_version: 2, metric_version: 'service-health-v1', scope: { kind: 'published', object_kind: 'api_format' },
|
||||
range: { from: '2026-09-11T00:00:00Z', to: '2026-09-11T06:00:00Z', timezone: 'UTC', time_basis: 'request_started_at' },
|
||||
generated_at: '2026-09-11T06:00:00Z', data_through: null, freshness: 'unknown',
|
||||
policy: { version: 'service-health-v1', minimum_samples: 20, healthy_threshold: 0.99, degraded_threshold: 0.95 },
|
||||
}
|
||||
function object(name = 'Chat API'): PublicHealthObject {
|
||||
return { id: 'chat', kind: 'api_format', name, status: 'healthy', request_count: 100,
|
||||
request_success: ratio, service_availability: ratio,
|
||||
coverage: { status: 'complete', sample_status: 'sufficient', classified_count: 100, excluded_count: 0, unknown_failure_count: 0, exclusion_policy: 'policy' },
|
||||
average_latency_ms: 450, latency_sample_count: 100, last_request_at: null, timeline: [],
|
||||
}
|
||||
}
|
||||
function summary() { return { meta, data: { status: 'healthy', object_count: 40, healthy_count: 40, degraded_count: 0, unavailable_count: 0, unknown_count: 0, requests: object() } } }
|
||||
function page(name = 'Chat API') { return { meta, data: { items: [object(name)], total: 40, limit: 25, offset: 0 } } }
|
||||
const mounted: Array<{ app: App; root: HTMLElement }> = []
|
||||
async function flush() { for (let i = 0; i < 8; i++) { await Promise.resolve(); await nextTick() } }
|
||||
async function mountView(admin = false, publicPage = !admin): Promise<{ root: HTMLElement; router: Router }> {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: defineComponent({ setup: () => () => h('div') }) }] })
|
||||
await router.push(admin ? '/admin/health' : '/status')
|
||||
await router.isReady()
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(HealthMonitorView, { isAdmin: admin, publicPage })
|
||||
app.use(router).mount(root)
|
||||
mounted.push({ app, root })
|
||||
await flush()
|
||||
return { root, router }
|
||||
}
|
||||
function button(root: HTMLElement, text: string) { return Array.from(root.querySelectorAll('button')).find(button => button.textContent?.includes(text))! }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.getAdminHealthSummary.mockResolvedValue(summary())
|
||||
api.getPublicHealthSummaryV2.mockResolvedValue(summary())
|
||||
api.getAdminHealthObjects.mockResolvedValue(page())
|
||||
api.getPublicHealthObjects.mockResolvedValue(page())
|
||||
api.getUserHealthSummary.mockResolvedValue(summary())
|
||||
api.getUserHealthObjects.mockResolvedValue(page())
|
||||
api.getAdminHealthObject.mockResolvedValue({ meta, data: object() })
|
||||
api.getPublicHealthObject.mockResolvedValue({ meta, data: object() })
|
||||
api.getHealthPublication.mockResolvedValue({ enabled: false, objects: [] })
|
||||
})
|
||||
afterEach(() => { for (const { app, root } of mounted.splice(0)) { app.unmount(); root.remove() } })
|
||||
|
||||
describe('independent health monitor', () => {
|
||||
it('keeps authenticated health independent of the public publication switch', async () => {
|
||||
const { root } = await mountView(false, false)
|
||||
expect(api.getUserHealthSummary).toHaveBeenCalledOnce()
|
||||
expect(api.getUserHealthObjects).toHaveBeenCalledOnce()
|
||||
expect(api.getPublicHealthSummaryV2).not.toHaveBeenCalled()
|
||||
expect(api.getAdminHealthSummary).not.toHaveBeenCalled()
|
||||
expect(root.textContent).toContain('Chat API')
|
||||
})
|
||||
it('uses only public queries and renders server totals independently of page rows', async () => {
|
||||
const { root, router } = await mountView()
|
||||
expect(api.getAdminHealthSummary).not.toHaveBeenCalled()
|
||||
expect(api.getAdminHealthObjects).not.toHaveBeenCalled()
|
||||
expect(root.textContent).toContain('40 个对象')
|
||||
expect(root.textContent).toContain('Chat API')
|
||||
expect(root.textContent).not.toContain('状态页发布')
|
||||
expect(root.textContent).not.toContain('提供商')
|
||||
const navigated = new Promise<void>(resolve => { const stop = router.afterEach(() => { stop(); resolve() }) })
|
||||
button(root, '模型').click()
|
||||
await navigated
|
||||
await flush()
|
||||
expect(router.currentRoute.value.query.kind).toBe('model')
|
||||
expect(api.getPublicHealthSummaryV2).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'model' }), expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('retains previous data when a manual refresh fails', async () => {
|
||||
const { root } = await mountView()
|
||||
api.getPublicHealthSummaryV2.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
root.querySelector<HTMLButtonElement>('[aria-label="刷新健康数据"]')!.click()
|
||||
await flush()
|
||||
expect(root.textContent).toContain('健康数据暂时不可用')
|
||||
expect(root.textContent).toContain('Chat API')
|
||||
})
|
||||
|
||||
it('keeps changed filters when an older request resolves late', async () => {
|
||||
let resolveOld!: (value: ReturnType<typeof page>) => void
|
||||
api.getPublicHealthObjects.mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
|
||||
const { root, router } = await mountView()
|
||||
await router.push('/status?kind=model')
|
||||
await flush()
|
||||
resolveOld(page('Obsolete data'))
|
||||
await flush()
|
||||
expect(root.textContent).toContain('Chat API')
|
||||
expect(root.textContent).not.toContain('Obsolete data')
|
||||
})
|
||||
|
||||
it('shows publication save failures without dismissing the editor', async () => {
|
||||
const { root } = await mountView(true)
|
||||
button(root, '状态页发布').click()
|
||||
await flush()
|
||||
api.saveHealthPublication.mockRejectedValueOnce({ response: { data: { detail: 'Duplicate public ID' } } })
|
||||
button(root, '保存').click()
|
||||
await flush()
|
||||
expect(api.saveHealthPublication).toHaveBeenCalledWith({ enabled: false, objects: [] })
|
||||
expect(root.textContent).toContain('Duplicate public ID')
|
||||
expect(root.textContent).toContain('状态页发布(/status)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { analysisShare, analyzeRows, mergeLiveAnalysis, rankAnalysisRows, type AnalysisRow } from '../operations/analysisHelpers'
|
||||
|
||||
function row(id: string, requestCount: number, failedCount: number | null): AnalysisRow {
|
||||
return { id, label: id, requestCount, failedCount, successRate: null, firstByteMs: null, outputTps: null }
|
||||
}
|
||||
|
||||
describe('automatic provider and model analysis', () => {
|
||||
it('merges observed load without deriving RPM or concurrency from historical request counts', () => {
|
||||
const history = [row('busy', 50_000, 12), row('idle', 8_000, 0)]
|
||||
const merged = mergeLiveAnalysis(history, [{ id: 'busy', label: 'busy', concurrency: 7, rpm: 83 }])
|
||||
expect(merged).toEqual([
|
||||
{ ...history[0], concurrency: 7, rpm: 83 },
|
||||
{ ...history[1], concurrency: 0, rpm: 0 },
|
||||
])
|
||||
expect(history[0].concurrency).toBeUndefined()
|
||||
expect(mergeLiveAnalysis(history, null)).toEqual(history.map(item => ({ ...item, concurrency: null, rpm: null })))
|
||||
expect(mergeLiveAnalysis(history, [])).toEqual(history.map(item => ({ ...item, concurrency: 0, rpm: 0 })))
|
||||
})
|
||||
|
||||
it('includes active entities before their requests are recorded without inventing historical rates', () => {
|
||||
expect(mergeLiveAnalysis([], [{ id: 'new-model', label: 'New model', concurrency: 3, rpm: 9 }])).toEqual([{
|
||||
id: 'new-model', label: 'New model', requestCount: 0, failedCount: 0,
|
||||
successRate: null, firstByteMs: null, outputTps: null, concurrency: 3, rpm: 9,
|
||||
}])
|
||||
expect(mergeLiveAnalysis(null, [])).toBeNull()
|
||||
})
|
||||
|
||||
it('prioritizes the largest failure impact, then traffic, and preserves unknown failure counts', () => {
|
||||
const rows = [row('unknown', 9000, null), row('healthy', 5000, 0), row('b', 200, 8), row('a', 200, 8), row('more-traffic', 300, 8), row('most-failures', 50, 12)]
|
||||
expect(rankAnalysisRows(rows).map(item => item.id)).toEqual(['most-failures', 'more-traffic', 'a', 'b', 'healthy', 'unknown'])
|
||||
expect(rows[0].id).toBe('unknown')
|
||||
})
|
||||
|
||||
it('computes each leader independently and uses the complete request and failure totals', () => {
|
||||
const result = analyzeRows([row('busy', 900, 1), row('failing', 100, 9)], 1000, 10)
|
||||
expect(result.traffic).toMatchObject({ state: 'ready', row: { id: 'busy' }, share: 90 })
|
||||
expect(result.failures).toMatchObject({ state: 'ready', row: { id: 'failing' }, share: 90 })
|
||||
})
|
||||
|
||||
it('never invents a percentage for missing, empty or inconsistent denominators', () => {
|
||||
expect(analysisShare(0, 10)).toBe(0)
|
||||
expect(analysisShare(1, 4)).toBe(25)
|
||||
for (const [part, total] of [[0, 0], [1, null], [null, 10], [NaN, 10], [1, Infinity], [11, 10], [-1, 10], [1, -10]]) {
|
||||
expect(analysisShare(part, total)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('distinguishes no requests, measured no failures, missing analysis and missing failures', () => {
|
||||
expect(analyzeRows([], 0, 0).traffic.state).toBe('empty')
|
||||
expect(analyzeRows([], 0, 0).failures.state).toBe('empty')
|
||||
expect(analyzeRows([row('busy', 20, 0)], 20, 0).failures.state).toBe('none')
|
||||
expect(analyzeRows(null, 20, 0).failures.state).toBe('unavailable')
|
||||
expect(analyzeRows([row('busy', 20, null)], 20, null).failures.state).toBe('unavailable')
|
||||
expect(analyzeRows([], 20, 5).traffic.state).toBe('unavailable')
|
||||
})
|
||||
|
||||
it('labels an observed failure leader as partial when other rows have no failure counts', () => {
|
||||
const result = analyzeRows([row('unknown', 100, null), row('measured', 10, 3)], 110, null)
|
||||
expect(result.failures).toMatchObject({ state: 'ready', row: { id: 'measured' }, share: null, partial: true })
|
||||
})
|
||||
|
||||
it('uses stable names for unidentified entities and does not drop rows beyond a top ten', () => {
|
||||
const rows = Array.from({ length: 12 }, (_, index) => ({ ...row(`model-${index}`, 10, 0), id: null }))
|
||||
const ranked = rankAnalysisRows(rows.reverse())
|
||||
expect(ranked).toHaveLength(12)
|
||||
expect(ranked[0].label).toBe('model-0')
|
||||
expect(analyzeRows(rows, 120, 0).traffic.row?.label).toBe('model-0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import { createApp, nextTick, type App } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import CostAnalysis from '@/views/admin/CostAnalysis.vue'
|
||||
import { setI18nLocale } from '@/i18n'
|
||||
|
||||
const overview = vi.hoisted(() => ({ costs: vi.fn(), breakdown: vi.fn(), exportCsv: vi.fn() }))
|
||||
const finance = vi.hoisted(() => ({ accounts: vi.fn(), expenses: vi.fn(), record: vi.fn(), void: vi.fn(), exportExpenses: vi.fn() }))
|
||||
vi.mock('@/api/overview', () => ({ overviewApi: overview }))
|
||||
vi.mock('@/api/providerFinance', () => ({ providerFinanceApi: finance }))
|
||||
vi.mock('@/components/charts/LineChart.vue', () => ({ default: { render: () => null } }))
|
||||
|
||||
const range = { from: '2026-09-01T00:00:00.000Z', to: '2026-09-02T00:00:00.000Z', timezone: 'UTC' }
|
||||
const metric = { request_count: 1357, input_tokens: 5000, output_tokens: 678, total_tokens: 5678, billable_amount: { value: '999999', currency: 'USD', basis: 'billable', status: 'known' } }
|
||||
const expense = { id: 'expense-1', provider_id: 'provider-1', provider_name: 'Provider One', kind: 'recharge', amount: '1.23', currency: 'USD', paid_at: range.from, period_start: null, period_end: null, note: 'Invoice one', external_reference: 'invoice-123', status: 'recorded', created_at: range.from, created_by: 'admin', voided_at: null }
|
||||
const totals = [{ currency: 'USD', amount: '432.10', recharge_amount: '400', subscription_amount: '32.10', other_amount: '0', entry_count: 20 }, { currency: 'EUR', amount: '87.65', recharge_amount: '20', subscription_amount: '67.65', other_amount: '0', entry_count: 20 }]
|
||||
const account = { provider_id: 'provider-1', provider_name: 'Provider One', is_active: true, billing_type: 'monthly_quota', quota: { limit: 100, used: 25, remaining: 75, currency: 'USD', period_start: range.from, expires_at: '2026-10-01T00:00:00Z' }, balance: { status: 'success', observed_at: range.to, currency: 'USD', available: 55, used: 45, granted: 100, plan_name: 'Monthly plan', subscriptions: [] } }
|
||||
const mounted: { app: App; root: HTMLElement }[] = []
|
||||
async function settle() { await Promise.resolve(); await Promise.resolve(); await nextTick(); await new Promise(resolve => setTimeout(resolve, 0)) }
|
||||
async function mount(extra = '') {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/admin/cost-analysis', component: CostAnalysis }, { path: '/admin/usage', component: { render: () => null } }] })
|
||||
await router.push({ path: '/admin/cost-analysis', query: { ...range, ...(extra ? { provider_id: extra, model: 'legacy-model', amount_basis: 'billable', group_by: 'model' } : {}) } })
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(CostAnalysis).use(router)
|
||||
app.mount(root)
|
||||
mounted.push({ app, root })
|
||||
await settle()
|
||||
return { root, router }
|
||||
}
|
||||
function section(root: ParentNode, selector: string) {
|
||||
const item = root.querySelector(selector)
|
||||
if (!item) throw new Error(`Missing section ${selector}`)
|
||||
return item
|
||||
}
|
||||
function button(root: ParentNode, name: string) {
|
||||
const item = [...root.querySelectorAll<HTMLButtonElement>('button')].find(item => item.textContent?.trim() === name)
|
||||
if (!item) throw new Error(`Missing button ${name}`)
|
||||
return item
|
||||
}
|
||||
function input(label: string) {
|
||||
const item = [...document.body.querySelectorAll('label')].find(item => item.textContent?.includes(label))?.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>('input,select,textarea')
|
||||
if (!item) throw new Error(`Missing input ${label}`)
|
||||
return item
|
||||
}
|
||||
function fill(label: string, value: string) {
|
||||
const field = input(label)
|
||||
field.value = value
|
||||
field.dispatchEvent(new Event(field.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }))
|
||||
}
|
||||
async function submitExpense() {
|
||||
section(document.body, '#provider-expense-form').dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||
await settle()
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
setI18nLocale('zh-CN')
|
||||
overview.costs.mockResolvedValue({ data: { summary: metric, timeseries: [{ ...metric, bucket_start: range.from }] } })
|
||||
overview.breakdown.mockImplementation(async query => ({ data: { items: [{ ...metric, id: 'provider-1', label: 'Provider One' }], total: 40, limit: query.limit, offset: query.offset } }))
|
||||
finance.accounts.mockResolvedValue({ observed_at: range.to, items: [account] })
|
||||
finance.expenses.mockImplementation(async query => ({ items: [expense], total: 40, limit: query.limit, offset: query.offset, totals, providers: [{ provider_id: 'provider-1', provider_name: 'Provider One', currency: 'USD', amount: '432.10', entry_count: 20 }] }))
|
||||
finance.record.mockResolvedValue({ item: expense })
|
||||
finance.void.mockResolvedValue({ item: { ...expense, status: 'void' } })
|
||||
})
|
||||
afterEach(() => { mounted.splice(0).forEach(({ app, root }) => { app.unmount(); root.remove() }); setI18nLocale('zh-CN') })
|
||||
|
||||
describe('provider expense analysis', () => {
|
||||
it('uses complete expense totals and keeps currencies separate from customer consumption', async () => {
|
||||
const { root } = await mount('ignored-provider')
|
||||
const summary = section(root, '[data-cost-summary]').textContent
|
||||
expect(summary).toContain('432.10 USD')
|
||||
expect(summary).toContain('87.65 EUR')
|
||||
expect(summary).not.toContain('1.23')
|
||||
expect(summary).not.toContain('999,999')
|
||||
expect(summary).not.toContain('519.75')
|
||||
expect(summary).toContain('1,357')
|
||||
expect(overview.costs.mock.lastCall?.[0]).not.toHaveProperty('provider_id')
|
||||
expect(overview.breakdown.mock.lastCall?.[0]).toMatchObject({ group_by: 'provider', sort: 'request_count' })
|
||||
expect(overview.breakdown.mock.lastCall?.[0]).not.toHaveProperty('model')
|
||||
expect(finance.expenses.mock.lastCall?.[0]).not.toHaveProperty('provider_id')
|
||||
})
|
||||
|
||||
it.each(['costs', 'providers', 'accounts', 'expenses'] as const)('keeps other sections available when %s fails', async failed => {
|
||||
const call = { costs: overview.costs, providers: overview.breakdown, accounts: finance.accounts, expenses: finance.expenses }[failed]
|
||||
call.mockRejectedValue(new Error(`${failed} unavailable`))
|
||||
const { root } = await mount()
|
||||
expect(overview.costs).toHaveBeenCalledTimes(1)
|
||||
expect(overview.breakdown).toHaveBeenCalledTimes(1)
|
||||
expect(finance.accounts).toHaveBeenCalledTimes(1)
|
||||
expect(finance.expenses).toHaveBeenCalledTimes(1)
|
||||
expect(root.textContent).toContain(`${failed} unavailable`)
|
||||
if (failed !== 'providers') expect(section(root, '[data-provider-usage]').textContent).toContain('Provider One')
|
||||
if (failed !== 'accounts') expect(section(root, '[data-provider-accounts]').textContent).toContain('Monthly plan')
|
||||
if (failed !== 'expenses') expect(section(root, '[data-cost-summary]').textContent).toContain('432.10 USD')
|
||||
if (failed === 'expenses') expect(section(root, '[data-cost-summary]').textContent).not.toContain('999,999')
|
||||
})
|
||||
|
||||
it('retains expense totals during pagination and clears them for another date scope', async () => {
|
||||
const { root, router } = await mount()
|
||||
finance.expenses.mockImplementation(() => new Promise(() => {}))
|
||||
section(root, '[data-provider-expenses]').querySelector<HTMLButtonElement>('[aria-label="下一页"]')?.click()
|
||||
await settle()
|
||||
expect(finance.expenses.mock.lastCall?.[0]).toMatchObject({ offset: 25 })
|
||||
expect(section(root, '[data-cost-summary]').textContent).toContain('432.10 USD')
|
||||
expect(overview.costs).toHaveBeenCalledTimes(1)
|
||||
expect(overview.breakdown).toHaveBeenCalledTimes(1)
|
||||
await router.replace({ query: { from: '2026-08-01T00:00:00Z', to: '2026-08-02T00:00:00Z', timezone: 'UTC' } })
|
||||
await settle()
|
||||
expect(finance.expenses.mock.lastCall?.[0]).toMatchObject({ offset: 0, from: '2026-08-01T00:00:00.000Z' })
|
||||
expect(section(root, '[data-cost-summary]').textContent).not.toContain('432.10 USD')
|
||||
expect(section(root, '[data-provider-expenses]').textContent).not.toContain('invoice-123')
|
||||
})
|
||||
|
||||
it('reuses a retry id for unchanged payment data and refreshes expenses after a successful save', async () => {
|
||||
finance.record.mockRejectedValue(new Error('connection lost'))
|
||||
const { root } = await mount()
|
||||
button(root, '登记支出').click()
|
||||
await settle()
|
||||
fill('提供商', 'provider-1')
|
||||
fill('实付金额', '10.25')
|
||||
fill('付款时间', '2026-09-01T13:20')
|
||||
await submitExpense()
|
||||
expect(finance.record).toHaveBeenCalledTimes(1)
|
||||
const original = finance.record.mock.lastCall?.[0]
|
||||
expect(original).toMatchObject({ amount: '10.25', provider_id: 'provider-1', currency: 'USD', paid_at: '2026-09-01T13:20:00.000Z' })
|
||||
expect(original.client_request_id).toBeTruthy()
|
||||
expect(document.body.textContent).toContain('connection lost')
|
||||
await submitExpense()
|
||||
expect(finance.record.mock.lastCall?.[0].client_request_id).toBe(original.client_request_id)
|
||||
fill('实付金额', '11.25')
|
||||
finance.record.mockResolvedValue({ item: { ...expense, amount: '11.25' } })
|
||||
await submitExpense()
|
||||
expect(finance.record.mock.lastCall?.[0].client_request_id).not.toBe(original.client_request_id)
|
||||
expect(finance.expenses).toHaveBeenCalledTimes(2)
|
||||
expect(document.body.querySelector('#provider-expense-form')).toBeNull()
|
||||
})
|
||||
|
||||
it('requires a confirmation before voiding and refreshes the ledger only after confirmation', async () => {
|
||||
const { root } = await mount()
|
||||
button(section(root, '[data-provider-expenses]'), '作废').click()
|
||||
await settle()
|
||||
expect(finance.void).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).toContain('确认作废这笔记录')
|
||||
button(document.body, '取消').click()
|
||||
await settle()
|
||||
expect(finance.void).not.toHaveBeenCalled()
|
||||
button(section(root, '[data-provider-expenses]'), '作废').click()
|
||||
await settle()
|
||||
button(document.body, '确认作废').click()
|
||||
await settle()
|
||||
expect(finance.void).toHaveBeenCalledWith('expense-1')
|
||||
expect(finance.expenses).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,266 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick, type App } from 'vue'
|
||||
import { createPinia, disposePinia, type Pinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import AdminDashboard from '@/views/admin/AdminDashboard.vue'
|
||||
import type { OverviewDashboardCharts, OverviewMetrics } from '@/api/overview'
|
||||
import { setI18nLocale } from '@/i18n'
|
||||
import { dayRange, modelDatasets, providerSlices } from '../dashboard/charts'
|
||||
import { dashboardSummary } from './fixtures/dashboardSummary'
|
||||
import { useAnnouncementStore } from '@/stores/announcements'
|
||||
|
||||
const api = vi.hoisted(() => ({ dashboardSummary: vi.fn(), summary: vi.fn(), dashboardTotal: vi.fn(), dashboardCharts: vi.fn() }))
|
||||
const legacyApi = vi.hoisted(() => ({ getStats: vi.fn(), getDailyStats: vi.fn() }))
|
||||
const announcementApi = vi.hoisted(() => ({ getAnnouncements: vi.fn(), getUserAnnouncements: vi.fn(), markAsRead: vi.fn() }))
|
||||
const auth = vi.hoisted(() => ({ canAccessAdmin: true, isAdmin: true, isAuditAdmin: false }))
|
||||
vi.mock('@/api/overview', () => ({ overviewApi: api }))
|
||||
vi.mock('@/api/dashboard', () => ({ dashboardApi: legacyApi }))
|
||||
vi.mock('@/features/usage/components/IntervalTimelineCard.vue', async () => { const { h, defineComponent } = await import('vue'); return { default: defineComponent({ props: { title: String, hours: Number, isAdmin: Boolean, refreshIntervalMs: Number }, setup: props => () => h('div', { 'data-timeline': '' }, props.title) }) } })
|
||||
vi.mock('@/stores/auth', () => ({ useAuthStore: () => auth }))
|
||||
vi.mock('@/api/announcements', () => ({ announcementApi }))
|
||||
vi.mock('@/components/charts/BarChart.vue', async () => { const { h } = await import('vue'); return { default: { render: () => h('div', { 'data-chart': 'bar' }) } } })
|
||||
vi.mock('@/components/charts/DoughnutChart.vue', async () => { const { h } = await import('vue'); return { default: { render: () => h('div', { 'data-chart': 'doughnut' }) } } })
|
||||
vi.mock('@/components/charts/LineChart.vue', async () => { const { h } = await import('vue'); return { default: { render: () => h('div', { 'data-chart': 'line' }) } } })
|
||||
vi.mock('@/components/common', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return { TimeRangePicker: defineComponent({
|
||||
props: { modelValue: { type: Object, default: () => ({}) } }, emits: ['update:modelValue'],
|
||||
setup: (props, { emit }) => () => h('button', { 'data-period-control': '', onClick: () => emit('update:modelValue', { ...props.modelValue, preset: 'last30days' }) }, 'Last 30 days'),
|
||||
}) }
|
||||
})
|
||||
vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
const passthrough = (name: string, tag = 'div') => defineComponent({ name, setup: (_, { slots }) => () => h(tag, slots.default?.()) })
|
||||
return {
|
||||
Card: passthrough('CardStub', 'section'), Badge: passthrough('BadgeStub', 'span'), Button: passthrough('ButtonStub', 'button'),
|
||||
Skeleton: passthrough('SkeletonStub'),
|
||||
Table: passthrough('TableStub', 'table'), TableHeader: passthrough('TableHeaderStub', 'thead'), TableBody: passthrough('TableBodyStub', 'tbody'), TableRow: passthrough('TableRowStub', 'tr'), TableHead: passthrough('TableHeadStub', 'th'), TableCell: passthrough('TableCellStub', 'td'),
|
||||
}
|
||||
})
|
||||
|
||||
const amount = (value: string | null, status = value === null ? 'unknown' : 'known') => ({ value, status, currency: 'USD', basis: 'billable' })
|
||||
const metrics = (request_count = 0): OverviewMetrics => ({
|
||||
request_count, successful_request_count: request_count, failed_request_count: 0, cancelled_request_count: 0, in_flight_request_count: 0, unclassified_failure_count: 0,
|
||||
input_tokens: request_count * 10, output_tokens: request_count * 10, total_tokens: request_count * 20, usage_active_users: 3, enabled_users: 8,
|
||||
success_rate: { value: request_count ? 1 : null, numerator: request_count, denominator: request_count }, latency_ms: { avg: 100, p50: 90, p95: 190, p99: 230, sample_count: request_count },
|
||||
rated_amount: amount('10'), billable_amount: amount('5'), quota_covered_amount: amount('2'), wallet_consumed_amount: amount('3'), wallet_debit_amount: amount('3'),
|
||||
})
|
||||
const charts = (): OverviewDashboardCharts => ({
|
||||
summary: metrics(777), series: [{ ...metrics(9), bucket_start: '2026-09-05T00:00:00Z' }],
|
||||
models: [{ ...metrics(9), id: 'gpt-example', label: 'gpt-example', bucket_start: '2026-09-05T00:00:00Z' }], providers: [{ ...metrics(9), id: 'provider-1', label: 'Provider 1' }],
|
||||
})
|
||||
const mounted: { app: App; root: HTMLElement; pinia: Pinia }[] = []
|
||||
async function settle() { for (let i = 0; i < 12; i++) { await Promise.resolve(); await nextTick() } await new Promise(resolve => setTimeout(resolve, 0)) }
|
||||
async function mount() {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(AdminDashboard)
|
||||
const pinia = createPinia()
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: AdminDashboard },
|
||||
{ path: '/admin/announcements', component: { render: () => null } },
|
||||
],
|
||||
})
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
useAnnouncementStore(pinia).resetSession('dashboard-admin')
|
||||
mounted.push({ app, root, pinia })
|
||||
await router.isReady()
|
||||
app.mount(root)
|
||||
await settle()
|
||||
return { root }
|
||||
}
|
||||
|
||||
function card(root: HTMLElement, label: string) {
|
||||
return Array.from(root.querySelectorAll('section')).find(section => Array.from(section.querySelectorAll('p')).some(p => p.textContent?.trim() === label))
|
||||
}
|
||||
|
||||
function primaryValue(root: HTMLElement, label: string) {
|
||||
return card(root, label)?.querySelector('p')?.nextElementSibling as HTMLElement | null | undefined
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })))
|
||||
setI18nLocale('zh-CN')
|
||||
vi.resetAllMocks()
|
||||
Object.assign(auth, { canAccessAdmin: true, isAdmin: true, isAuditAdmin: false })
|
||||
api.dashboardSummary.mockResolvedValue(dashboardSummary())
|
||||
announcementApi.getUserAnnouncements.mockResolvedValue({ items: [], total: 0, unread_count: 0 })
|
||||
legacyApi.getStats.mockResolvedValue({
|
||||
stats: [{ name: 'Legacy request metric', value: '999999', icon: 'Activity' }],
|
||||
system_health: { avg_response_time: 1.5, error_rate: 2, error_requests: 1, fallback_count: 3, total_requests: 50 },
|
||||
cost_stats: { total_cost: 42.5, total_actual_cost: 35, cost_savings: 7.5 },
|
||||
cache_stats: { cache_creation_tokens: 10, cache_read_tokens: 20, total_cache_tokens: 30 },
|
||||
})
|
||||
legacyApi.getDailyStats.mockResolvedValue({
|
||||
daily_stats: [{ date: '2026-09-05', requests: 777, tokens: 8888, cost: 3.5, avg_response_time: 1.2, unique_models: 1, unique_providers: 1, model_breakdown: [{ model: 'gpt-example', cost: 3.5 }] }],
|
||||
provider_summary: [{ provider: 'Primary', requests: 777, tokens: 8888, cost: 3.5 }], model_summary: [], period: { start_date: '2026-09-05', end_date: '2026-09-11', days: 7 },
|
||||
})
|
||||
})
|
||||
afterEach(() => { for (const { app, root, pinia } of mounted.splice(0)) { app.unmount(); disposePinia(pinia); root.remove() } vi.unstubAllGlobals() })
|
||||
|
||||
function metric(root: HTMLElement, key: string) {
|
||||
return root.querySelector(`[data-request-metric="${key}"]`)
|
||||
}
|
||||
|
||||
function metricValue(root: HTMLElement, key: string) {
|
||||
return metric(root, key)?.lastElementChild?.textContent?.trim()
|
||||
}
|
||||
|
||||
describe('administrator dashboard compact presentation', () => {
|
||||
it('shows five primary cards, six request metrics, announcements, and the activity streak', async () => {
|
||||
const { root } = await mount()
|
||||
expect(card(root, '今日请求')?.textContent).toContain('103')
|
||||
expect(card(root, '今日请求')?.textContent).toContain('总请求 12,345')
|
||||
const tokens = card(root, '今日 Token')
|
||||
expect(tokens?.textContent).toContain('12K / 3.46K')
|
||||
expect(tokens?.textContent).toContain('总 Token 1.23M')
|
||||
const cache = card(root, '今日缓存')
|
||||
expect(tokens?.nextElementSibling).toBe(cache)
|
||||
expect(cache?.textContent).toContain('30.00%')
|
||||
expect(cache?.textContent).toContain('总缓存 45.00%')
|
||||
expect(card(root, '今日消费')?.textContent).toContain('$9.87')
|
||||
expect(card(root, '今日消费')?.textContent).toContain('总消费 $98.76')
|
||||
const users = card(root, '今日活跃用户')
|
||||
expect(users?.textContent).toContain('总用户 14')
|
||||
expect(users?.textContent).toContain('+2')
|
||||
expect(users?.textContent).toContain('-1')
|
||||
expect(metric(root, 'first-byte')?.textContent).toContain('80ms')
|
||||
expect(metric(root, 'response')?.textContent).toContain('1.52s')
|
||||
expect(Array.from(root.querySelectorAll('[data-request-metric]'), item => item.getAttribute('data-request-metric')))
|
||||
.toEqual(['first-byte', 'response', 'concurrency-avg', 'concurrency-peak', 'stream', 'standard'])
|
||||
expect(metric(root, 'cache-hit')).toBeNull()
|
||||
expect(metric(root, 'cache-tokens')).toBeNull()
|
||||
expect(metric(root, 'concurrency-avg')?.textContent).toContain('2.5')
|
||||
expect(metric(root, 'concurrency-peak')?.textContent).toContain('8')
|
||||
expect(metric(root, 'stream')?.textContent).toContain('90')
|
||||
expect(metric(root, 'standard')?.textContent).toContain('13')
|
||||
const activity = root.querySelector('[data-dashboard-activity]')
|
||||
expect(activity?.textContent).toContain('连续活跃')
|
||||
expect(activity?.textContent).toContain('2 / 413')
|
||||
expect(root.textContent?.indexOf('连续活跃')).toBeLessThan(root.textContent?.indexOf('统计周期') ?? -1)
|
||||
expect(root.querySelector('[data-timeline]')?.textContent).toContain('最近24小时')
|
||||
expect(root.textContent).not.toContain('P95')
|
||||
expect(root.textContent).not.toContain('P99')
|
||||
expect(root.textContent).not.toContain('成功率')
|
||||
expect(root.textContent).not.toContain('RPM')
|
||||
expect(root.textContent).not.toContain('TPM')
|
||||
expect(root.textContent).not.toContain('统计自')
|
||||
expect(root.textContent).not.toContain('累计统计时间')
|
||||
expect(card(root, '今日请求')?.querySelector('svg')).toBeNull()
|
||||
expect(metric(root, 'first-byte')?.closest('section')?.querySelector('svg')).toBeNull()
|
||||
expect(api.dashboardSummary).toHaveBeenCalledTimes(1)
|
||||
expect(api.summary).not.toHaveBeenCalled()
|
||||
expect(api.dashboardTotal).not.toHaveBeenCalled()
|
||||
expect(api.dashboardCharts).not.toHaveBeenCalled()
|
||||
expect(legacyApi.getStats).not.toHaveBeenCalled()
|
||||
expect(announcementApi.getAnnouncements).not.toHaveBeenCalled()
|
||||
expect(announcementApi.getUserAnnouncements).toHaveBeenCalledExactlyOnceWith({ limit: 20, offset: 0 })
|
||||
expect(root.querySelector('#announcements-section')?.textContent).toContain('系统公告')
|
||||
expect(root.querySelector('#announcements-section a')?.getAttribute('href')).toBe('/admin/announcements')
|
||||
})
|
||||
|
||||
it('keeps today and all-period totals fixed when the chart period changes', async () => {
|
||||
const { root } = await mount()
|
||||
root.querySelector<HTMLButtonElement>('[data-period-control]')!.click()
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
await settle()
|
||||
expect(legacyApi.getDailyStats).toHaveBeenCalledTimes(2)
|
||||
expect(api.dashboardSummary).toHaveBeenCalledTimes(1)
|
||||
expect(card(root, '今日请求')?.textContent).toContain('12,345')
|
||||
})
|
||||
|
||||
it('uses the same snapshot for audit administrators', async () => {
|
||||
Object.assign(auth, { isAdmin: false, isAuditAdmin: true, canAccessAdmin: true })
|
||||
const { root } = await mount()
|
||||
expect(root.textContent).toContain('AUDIT MODE')
|
||||
expect(card(root, '今日请求')?.textContent).toContain('12,345')
|
||||
expect(api.dashboardSummary).toHaveBeenCalledTimes(1)
|
||||
expect(legacyApi.getStats).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps unavailable values unknown and does not invent cache hit rate from output tokens', async () => {
|
||||
const snapshot = dashboardSummary()
|
||||
Object.assign(snapshot.today, { input_tokens: null, output_tokens: null, cache_read_tokens: 0, cache_input_tokens: 0, avg_first_byte_ms: null, avg_response_ms: null, billable_amount: { value: null, status: 'unknown', currency: 'USD', basis: 'billable' } })
|
||||
Object.assign(snapshot.total, { cache_read_tokens: null, cache_input_tokens: null })
|
||||
snapshot.concurrency = { avg: null, peak: null, observed_from: null, observed_through: null, scope: 'node', coverage: 'unavailable' }
|
||||
api.dashboardSummary.mockResolvedValue(snapshot)
|
||||
const { root } = await mount()
|
||||
for (const key of ['first-byte', 'response', 'concurrency-avg', 'concurrency-peak']) {
|
||||
expect(metricValue(root, key)).toBe('—')
|
||||
}
|
||||
expect(primaryValue(root, '今日 Token')?.textContent?.trim()).toBe('— / —')
|
||||
expect(primaryValue(root, '今日消费')?.textContent?.trim()).toBe('—')
|
||||
expect(primaryValue(root, '今日缓存')?.textContent?.trim()).toBe('—')
|
||||
expect(card(root, '今日缓存')?.textContent).toContain('总缓存 —')
|
||||
expect(card(root, '今日缓存')?.textContent).not.toContain('0.00%')
|
||||
expect(root.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('puts collection boundaries, partial prices, and node observation coverage in tooltips only', async () => {
|
||||
const snapshot = dashboardSummary()
|
||||
snapshot.today.billable_amount.status = 'known_subtotal'
|
||||
api.dashboardSummary.mockResolvedValue(snapshot)
|
||||
const { root } = await mount()
|
||||
expect(card(root, '今日请求')?.querySelector('p')?.title).toContain('统计自')
|
||||
expect(primaryValue(root, '今日消费')?.querySelector('[title]')?.getAttribute('title')).toContain('部分请求价格未知')
|
||||
const concurrency = metric(root, 'concurrency-avg')?.closest('section')
|
||||
expect(concurrency?.title).toContain('当前节点')
|
||||
expect(concurrency?.title).toContain('部分时段')
|
||||
expect(concurrency?.title).toContain('02:00')
|
||||
expect(concurrency?.title).toContain('04:00')
|
||||
expect(root.textContent).not.toContain('已知小计')
|
||||
expect(root.textContent).not.toContain('部分时段')
|
||||
expect(root.textContent).not.toContain('统计自')
|
||||
const heatmapDay = root.querySelector<HTMLElement>('[title*="250"]')
|
||||
expect(heatmapDay?.title).not.toContain('$0')
|
||||
expect(heatmapDay?.title).not.toContain('tokens')
|
||||
})
|
||||
|
||||
it('leaves primary and request values blank on failure without error text or a retry button', async () => {
|
||||
api.dashboardSummary.mockRejectedValue(new Error('Unavailable'))
|
||||
const { root } = await mount()
|
||||
expect(root.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(root.textContent).not.toContain('加载失败')
|
||||
expect(root.textContent).not.toContain('重试')
|
||||
expect(card(root, '今日请求')?.textContent).toContain('—')
|
||||
for (const key of ['first-byte', 'response', 'concurrency-avg', 'concurrency-peak', 'stream', 'standard']) {
|
||||
expect(metricValue(root, key)).toBe('—')
|
||||
}
|
||||
expect(root.querySelector('[data-chart="bar"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dashboard chart accounting', () => {
|
||||
it('preserves the full provider sum when collapsing additional groups and excludes unknown amounts', () => {
|
||||
const rows = Array.from({ length: 105 }, (_, i) => ({ ...metrics(1), id: String(i), label: `Provider ${i}`, billable_amount: amount(String(i + 1)) }))
|
||||
rows.push({ ...metrics(1), id: 'unknown', label: 'Unknown cost', billable_amount: amount(null) })
|
||||
const slices = providerSlices(rows, 'Unknown provider', 'Others')
|
||||
expect(slices).toHaveLength(7)
|
||||
expect(slices.reduce((sum, slice) => sum + slice.value, 0)).toBe(5565)
|
||||
expect(slices.some(slice => slice.label === 'Unknown cost')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps unknown model amounts null and does not use period percentages to distribute daily amounts', () => {
|
||||
const data = charts()
|
||||
data.series.push({ ...metrics(1), bucket_start: '2026-09-06T00:00:00Z' })
|
||||
data.models = [
|
||||
{ ...metrics(1), id: 'gpt-example', label: 'gpt-example', bucket_start: '2026-09-05T00:00:00+00:00', billable_amount: amount('3') },
|
||||
{ ...metrics(1), id: 'gpt-example', label: 'gpt-example', bucket_start: data.series[1]!.bucket_start, billable_amount: amount(null) },
|
||||
]
|
||||
expect(modelDatasets(data, 'Unknown model', 'Others')[0]).toMatchObject({ label: 'gpt-example', data: [3, null] })
|
||||
})
|
||||
|
||||
it('uses a 23-hour local day during daylight saving and clips it to report boundaries', () => {
|
||||
const dstRange = { from: '2026-03-07T05:00:00Z', to: '2026-03-10T04:00:00Z', timezone: 'America/New_York' }
|
||||
expect(dayRange('2026-03-08T05:00:00Z', dstRange)).toEqual({ from: '2026-03-08T05:00:00.000Z', to: '2026-03-09T04:00:00.000Z', timezone: dstRange.timezone })
|
||||
expect(dayRange('2026-03-08T05:00:00Z', { ...dstRange, to: '2026-03-08T18:00:00Z' }).to).toBe('2026-03-08T18:00:00.000Z')
|
||||
})
|
||||
|
||||
it('uses the first valid local minute when a timezone skips midnight', () => {
|
||||
const santiago = { from: '2026-09-05T04:00:00Z', to: '2026-09-08T03:00:00Z', timezone: 'America/Santiago' }
|
||||
expect(dayRange('2026-09-05T04:00:00Z', santiago).to).toBe('2026-09-06T04:00:00.000Z')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { OverviewDashboardSummary } from '@/api/overview'
|
||||
|
||||
export function dashboardSummary(): OverviewDashboardSummary {
|
||||
const amount = (value: string) => ({ value, currency: 'USD', basis: 'billable', status: 'known' })
|
||||
return {
|
||||
stats_since: '2026-09-18T00:00:00Z', generated_at: '2026-09-19T04:00:00Z', timezone: 'UTC',
|
||||
today_from: '2026-09-19T00:00:00Z', window_seconds: 14400,
|
||||
today: {
|
||||
request_count: 103, input_tokens: 12000, output_tokens: 3456, total_tokens: 15456,
|
||||
billable_amount: amount('9.87'), active_users: 8,
|
||||
cache_read_tokens: 6000, cache_creation_tokens: 1000, cache_input_tokens: 20000,
|
||||
avg_first_byte_ms: 80, avg_response_ms: 1520, stream_requests: 90, standard_requests: 13,
|
||||
},
|
||||
total: { request_count: 12345, total_tokens: 1234567, billable_amount: amount('98.76'), cache_read_tokens: 360000, cache_input_tokens: 800000 },
|
||||
users: { total: 14, created_today: 2, deleted_today: 1 },
|
||||
consecutive_active_days: 2, active_days: 413,
|
||||
activity_days: [{ date: '2026-09-18', requests: 250 }, { date: '2026-09-19', requests: 103 }],
|
||||
concurrency: { avg: 2.5, peak: 8, observed_from: '2026-09-19T02:00:00Z', observed_through: '2026-09-19T04:00:00Z', scope: 'node', coverage: 'partial' },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { createApp, h, nextTick, ref, type App } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import PerformanceView from '../operations/PerformanceView.vue'
|
||||
import { setI18nLocale } from '@/i18n'
|
||||
import type { OverviewExecutionActivity } from '@/api/overview'
|
||||
|
||||
const api = vi.hoisted(() => ({ performance: vi.fn() }))
|
||||
vi.mock('@/api/overview', () => ({ overviewApi: api }))
|
||||
vi.mock('@/components/charts/BarChart.vue', () => ({ default: { render: () => null } }))
|
||||
vi.mock('@/components/charts/LineChart.vue', () => ({ default: { render: () => null } }))
|
||||
vi.mock('../components/OverviewTrend.vue', () => ({ default: { render: () => null } }))
|
||||
vi.mock('../components/OverviewStatus.vue', () => ({ default: { render: () => null } }))
|
||||
|
||||
let app: App | undefined
|
||||
beforeEach(() => { vi.clearAllMocks(); setI18nLocale('zh-CN') })
|
||||
afterEach(() => { app?.unmount(); app = undefined })
|
||||
|
||||
async function settle() {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
}
|
||||
async function mount(url = '/admin/operations?timezone=UTC', initialActivity?: OverviewExecutionActivity | null) {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/admin/operations', component: PerformanceView },
|
||||
{ path: '/admin/usage', component: { render: () => null } },
|
||||
] })
|
||||
await router.push(url)
|
||||
const root = document.createElement('div')
|
||||
const activity = ref(initialActivity)
|
||||
app = createApp({ render: () => h(PerformanceView, { revision: 0, activity: activity.value }) }).use(router)
|
||||
app.mount(root)
|
||||
await settle()
|
||||
return { root, router, activity }
|
||||
}
|
||||
function performance(rate: number | null, errors: number | null = 3, requests = 100) {
|
||||
return {
|
||||
request_count: requests, success_count: errors == null ? null : requests - errors,
|
||||
error_count: errors, success_rate: rate,
|
||||
avg_first_byte_time_ms: null as number | null, avg_output_tps: null as number | null,
|
||||
avg_response_time_ms: null, slow_request_count: 3,
|
||||
}
|
||||
}
|
||||
function provider(id: string, rate: number | null, errors: number | null = 3, requests = 100) {
|
||||
return { ...performance(rate, errors, requests), provider_id: id, provider: id }
|
||||
}
|
||||
function model(name: string | null, rate: number | null, errors: number | null = 3, requests = 100) {
|
||||
return { ...performance(rate, errors, requests), model: name }
|
||||
}
|
||||
function response(providers = [provider('example', 97)], models = [model('model-a', 95)]) {
|
||||
return { data: {
|
||||
summary: { request_count: 300, latency_ms: { p95: null } },
|
||||
timeseries: [], errors: [], models,
|
||||
providers: {
|
||||
summary: { avg_first_byte_time_ms: 0, avg_output_tps: null, slow_request_count: 3 },
|
||||
providers, timeline: [],
|
||||
},
|
||||
} }
|
||||
}
|
||||
function analysis(root: HTMLElement, dimension: 'provider' | 'model') {
|
||||
return root.querySelector<HTMLElement>(`[data-performance-analysis="${dimension}"]`)!
|
||||
}
|
||||
function rows(root: HTMLElement, dimension: 'provider' | 'model') {
|
||||
return Array.from(analysis(root, dimension).querySelectorAll<HTMLTableRowElement>('[data-analysis-row]'))
|
||||
}
|
||||
function liveActivity(): OverviewExecutionActivity {
|
||||
return {
|
||||
observed_at: '2026-09-20T08:00:00Z', observed_from: '2026-09-20T07:59:00Z',
|
||||
window_seconds: 60, observed_window_seconds: 60, scope: { kind: 'node' }, coverage: 'complete',
|
||||
providers: [{ provider_id: 'example', provider: 'Example', requests_per_minute: 83, current_concurrency: 7 }],
|
||||
models: [{ model: 'model-a', requests_per_minute: 61, current_concurrency: 4 }],
|
||||
}
|
||||
}
|
||||
function loadValues(root: HTMLElement, dimension: 'provider' | 'model') {
|
||||
return rows(root, dimension).map(row => ({
|
||||
entity: row.dataset.analysisRow,
|
||||
concurrency: row.querySelector('[data-analysis-concurrency]')?.textContent?.trim(),
|
||||
rpm: row.querySelector('[data-analysis-rpm]')?.textContent?.trim(),
|
||||
}))
|
||||
}
|
||||
function linkQuery(link: HTMLAnchorElement) {
|
||||
const url = new URL(link.getAttribute('href')!, 'http://localhost')
|
||||
expect(url.pathname).toBe('/admin/usage')
|
||||
return Object.fromEntries(url.searchParams)
|
||||
}
|
||||
|
||||
const fixedRange = {
|
||||
from: '2026-09-18T00:00:00.000Z', to: '2026-09-19T00:00:00.000Z', timezone: 'UTC',
|
||||
}
|
||||
const legacyUrl = '/admin/operations?from=2026-09-18T00:00:00Z&to=2026-09-19T00:00:00Z&timezone=UTC&model=old-model&api_format=openai&is_stream=false&has_format_conversion=true&provider_id=old-provider&slow_threshold_ms=7500&status=failed'
|
||||
|
||||
describe('operations automatic analysis', () => {
|
||||
it('shows provider and model reports together without filters and uses the backend model aggregates', async () => {
|
||||
api.performance.mockResolvedValue(response(
|
||||
[provider('provider-a', 97, 3, 100), provider('provider-b', 95, 10, 200)],
|
||||
[model('shared-model', 96.25, 6, 160)],
|
||||
))
|
||||
const { root } = await mount()
|
||||
expect(root.querySelectorAll('[data-performance-analysis]').length).toBe(2)
|
||||
expect(root.querySelector('form, input, select, [role="tab"], [role="tablist"]')).toBeNull()
|
||||
expect(rows(root, 'provider')).toHaveLength(2)
|
||||
const modelRows = rows(root, 'model')
|
||||
expect(modelRows).toHaveLength(1)
|
||||
expect(modelRows[0].querySelector('[data-analysis-entity-link]')?.textContent).toContain('shared-model')
|
||||
expect(modelRows[0].querySelectorAll('td')[1].textContent).toContain('160')
|
||||
expect(modelRows[0].querySelectorAll('td')[2].textContent).toContain('96.25%')
|
||||
expect(root.querySelector<HTMLDetailsElement>('[data-operations-diagnostics]')?.open).toBe(false)
|
||||
expect(api.performance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('orders measured failures first, uses percentage points and distinguishes unavailable values from zero', async () => {
|
||||
const measured = provider('measured', 97.25, 11)
|
||||
measured.avg_first_byte_time_ms = 0
|
||||
measured.avg_output_tps = 0
|
||||
api.performance.mockResolvedValue(response([
|
||||
provider('unavailable', null, null),
|
||||
provider('no-failures', 100, 0, 1_000),
|
||||
measured,
|
||||
provider('zero-success', 0, 25),
|
||||
], [model('model-zero', 0, 100), model('model-unknown', null, null)]))
|
||||
const { root } = await mount()
|
||||
const providerRows = rows(root, 'provider')
|
||||
expect(providerRows.map(row => row.dataset.analysisRow)).toEqual(['zero-success', 'measured', 'no-failures', 'unavailable'])
|
||||
expect(providerRows[0].querySelectorAll('td')[2].textContent).toContain('0%')
|
||||
expect(providerRows[1].querySelectorAll('td')[2].textContent).toContain('97.25%')
|
||||
expect(providerRows[3].querySelectorAll('td')[2].textContent?.trim()).toMatch(/^-/)
|
||||
expect(providerRows[1].querySelectorAll('td')[5].textContent?.trim()).toBe('0 ms')
|
||||
expect(providerRows[1].querySelectorAll('td')[6].textContent?.trim()).toBe('0')
|
||||
expect(providerRows[3].querySelectorAll('td')[5].textContent?.trim()).toBe('-')
|
||||
expect(providerRows[3].querySelectorAll('td')[6].textContent?.trim()).toBe('-')
|
||||
const modelRows = rows(root, 'model')
|
||||
expect(modelRows[0].querySelectorAll('td')[2].textContent).toContain('0%')
|
||||
expect(modelRows[1].querySelectorAll('td')[2].textContent?.trim()).toMatch(/^-/)
|
||||
})
|
||||
|
||||
it('shows current provider and model load independently of the selected historical range', async () => {
|
||||
api.performance.mockResolvedValue(response())
|
||||
const { root, router } = await mount(legacyUrl, liveActivity())
|
||||
expect(loadValues(root, 'provider')).toEqual([{ entity: 'example', concurrency: '7', rpm: '83' }])
|
||||
expect(loadValues(root, 'model')).toEqual([{ entity: 'model-a', concurrency: '4', rpm: '61' }])
|
||||
expect(analysis(root, 'provider').querySelector('thead')?.textContent).toContain('当前并发')
|
||||
expect(analysis(root, 'provider').querySelector('thead')?.textContent).toContain('近 60 秒')
|
||||
|
||||
api.performance.mockResolvedValue(response([provider('example', 99, 10, 1_000)], [model('model-a', 99, 10, 1_000)]))
|
||||
await router.replace({ query: { from: '2026-09-01T00:00:00Z', to: '2026-09-19T00:00:00Z', timezone: 'UTC' } })
|
||||
await settle()
|
||||
expect(api.performance).toHaveBeenCalledTimes(2)
|
||||
expect(rows(root, 'provider')[0].querySelectorAll('td')[1].textContent).toContain('1,000')
|
||||
expect(loadValues(root, 'provider')).toEqual([{ entity: 'example', concurrency: '7', rpm: '83' }])
|
||||
expect(loadValues(root, 'model')).toEqual([{ entity: 'model-a', concurrency: '4', rpm: '61' }])
|
||||
})
|
||||
|
||||
it('distinguishes measured zero load from missing or incomplete live coverage', async () => {
|
||||
api.performance.mockResolvedValue(response())
|
||||
const { root, activity } = await mount()
|
||||
for (const dimension of ['provider', 'model'] as const) {
|
||||
expect(loadValues(root, dimension)[0]).toMatchObject({ concurrency: '-', rpm: '-' })
|
||||
}
|
||||
activity.value = { ...liveActivity(), providers: [], models: [] }
|
||||
await settle()
|
||||
for (const dimension of ['provider', 'model'] as const) {
|
||||
expect(loadValues(root, dimension)[0]).toMatchObject({ concurrency: '0', rpm: '0' })
|
||||
}
|
||||
activity.value = { ...liveActivity(), coverage: 'partial' }
|
||||
await settle()
|
||||
for (const dimension of ['provider', 'model'] as const) {
|
||||
expect(loadValues(root, dimension)[0]).toMatchObject({ concurrency: '-', rpm: '-' })
|
||||
}
|
||||
activity.value = null
|
||||
await settle()
|
||||
expect(loadValues(root, 'provider')[0]).toMatchObject({ concurrency: '-', rpm: '-' })
|
||||
expect(api.performance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps new active providers and models visible before historical usage has been recorded', async () => {
|
||||
api.performance.mockResolvedValue(response([], []))
|
||||
const { root } = await mount(legacyUrl, liveActivity())
|
||||
expect(loadValues(root, 'provider')).toEqual([{ entity: 'example', concurrency: '7', rpm: '83' }])
|
||||
expect(loadValues(root, 'model')).toEqual([{ entity: 'model-a', concurrency: '4', rpm: '61' }])
|
||||
for (const dimension of ['provider', 'model'] as const) {
|
||||
const row = rows(root, dimension)[0]
|
||||
expect(row.querySelectorAll('td')[1].textContent?.trim()).toMatch(/^0/)
|
||||
expect(row.querySelector('[data-analysis-success-rate]')?.textContent).toBe('-')
|
||||
expect(row.querySelectorAll('td')[5].textContent?.trim()).toBe('-')
|
||||
expect(row.querySelectorAll('td')[6].textContent?.trim()).toBe('-')
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores legacy filters in requests and limits drilldowns to the selected range and analysis entity', async () => {
|
||||
api.performance.mockResolvedValue(response())
|
||||
const { root, router } = await mount(legacyUrl)
|
||||
expect(api.performance.mock.lastCall?.[0]).toMatchObject(fixedRange)
|
||||
for (const key of ['model', 'api_format', 'is_stream', 'has_format_conversion', 'provider_id', 'slow_threshold_ms', 'status']) {
|
||||
expect(api.performance.mock.lastCall?.[0][key]).toBeUndefined()
|
||||
}
|
||||
for (const [dimension, entity, key] of [['provider', 'example', 'provider_id'], ['model', 'model-a', 'model']] as const) {
|
||||
const card = analysis(root, dimension)
|
||||
const entityLink = card.querySelector<HTMLAnchorElement>(`[data-analysis-entity-link="${entity}"]`)!
|
||||
const failureLink = card.querySelector<HTMLAnchorElement>(`[data-analysis-failure-link="${entity}"]`)!
|
||||
expect(linkQuery(entityLink)).toEqual({ ...fixedRange, [key]: entity })
|
||||
expect(linkQuery(failureLink)).toEqual({ ...fixedRange, [key]: entity, status: 'failed' })
|
||||
}
|
||||
await router.replace({ query: { ...router.currentRoute.value.query, model: 'another-old-model', provider_id: 'another-old-provider', slow_threshold_ms: '15000' } })
|
||||
await settle()
|
||||
expect(api.performance).toHaveBeenCalledTimes(1)
|
||||
expect(rows(root, 'model')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([null, ''])('does not turn an unattributed model (%s) into an unrestricted request link', async missingModel => {
|
||||
api.performance.mockResolvedValue(response([], [model(missingModel, 100, 0)]))
|
||||
const { root } = await mount(legacyUrl)
|
||||
expect(rows(root, 'provider')).toHaveLength(0)
|
||||
const modelRows = rows(root, 'model')
|
||||
expect(modelRows).toHaveLength(1)
|
||||
expect(modelRows[0].querySelector('a')).toBeNull()
|
||||
expect(modelRows[0].querySelectorAll('td')[1].textContent).toContain('100')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { presetRange, rangeFromQuery, zonedInput, zonedInstant } from '../query'
|
||||
|
||||
describe('overview time range contract', () => {
|
||||
it('keeps a rolling hour precise across midnight', () => {
|
||||
expect(presetRange('last1hour', 'Asia/Shanghai', new Date('2026-09-10T16:20:42Z'))).toEqual({
|
||||
from: '2026-09-10T15:20:42.000Z', to: '2026-09-10T16:20:42.000Z', timezone: 'Asia/Shanghai',
|
||||
})
|
||||
})
|
||||
it('uses the report timezone for today rather than the browser calendar', () => {
|
||||
expect(presetRange('today', 'America/New_York', new Date('2026-03-08T16:00:00Z')).from).toBe('2026-03-08T05:00:00.000Z')
|
||||
})
|
||||
it('rejects a skipped DST time and picks the earlier repeated time', () => {
|
||||
expect(zonedInstant('2026-03-08T02:30', 'America/New_York')).toBeNull()
|
||||
expect(zonedInstant('2026-11-01T01:30', 'America/New_York')).toBe('2026-11-01T05:30:00.000Z')
|
||||
expect(zonedInput('2026-11-01T05:30:00Z', 'America/New_York')).toBe('2026-11-01T01:30')
|
||||
})
|
||||
it('normalizes offset timestamps and preserves the half-open boundary', () => {
|
||||
const fallback = presetRange('last7days')
|
||||
expect(rangeFromQuery({ from: '2026-09-11T00:00:00+08:00', to: '2026-09-11T00:01:00+08:00', timezone: 'Asia/Shanghai' }, fallback)).toEqual({
|
||||
from: '2026-09-10T16:00:00.000Z', to: '2026-09-10T16:01:00.000Z', timezone: 'Asia/Shanghai',
|
||||
})
|
||||
expect(rangeFromQuery({ from: 'invalid', to: 'invalid' }, fallback)).toBe(fallback)
|
||||
})
|
||||
it('migrates legacy calendar dates across DST and yesterday presets', () => {
|
||||
const fallback = { from: '2026-03-01T00:00:00Z', to: '2026-03-09T16:00:00Z', timezone: 'America/New_York' }
|
||||
expect(rangeFromQuery({ start_date: '2026-03-08', end_date: '2026-03-08', timezone: 'America/New_York' }, fallback)).toEqual({ from: '2026-03-08T05:00:00.000Z', to: '2026-03-09T04:00:00.000Z', timezone: 'America/New_York' })
|
||||
expect(rangeFromQuery({ preset: 'yesterday' }, fallback)).toEqual({ from: '2026-03-08T05:00:00.000Z', to: '2026-03-09T04:00:00.000Z', timezone: 'America/New_York' })
|
||||
expect(rangeFromQuery({ preset: 'last7days' }, fallback)).toEqual({ from: '2026-03-03T05:00:00.000Z', to: '2026-03-10T04:00:00.000Z', timezone: 'America/New_York' })
|
||||
})
|
||||
it('aligns generated presets to seconds for request-detail compatibility', () => {
|
||||
const range = presetRange('last1hour', 'UTC', new Date('2026-09-11T10:25:06.987Z'))
|
||||
expect(range.from).toBe('2026-09-11T09:25:06.000Z')
|
||||
expect(range.to).toBe('2026-09-11T10:25:06.000Z')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
|
||||
describe('overview request lifecycle', () => {
|
||||
it('aborts replaced requests and never publishes a stale result', async () => {
|
||||
const source = ref('first')
|
||||
const requests: { signal: AbortSignal; resolve: (value: string) => void }[] = []
|
||||
const fetcher = vi.fn((signal: AbortSignal) => new Promise<string>(resolve => requests.push({ signal, resolve })))
|
||||
const scope = effectScope()
|
||||
const state = scope.run(() => useOverviewRequest(source, fetcher))!
|
||||
source.value = 'second'
|
||||
await nextTick()
|
||||
expect(requests[0]?.signal.aborted).toBe(true)
|
||||
requests[1]!.resolve('second result')
|
||||
await Promise.resolve()
|
||||
requests[0]!.resolve('first result')
|
||||
await Promise.resolve()
|
||||
expect(state.data.value).toBe('second result')
|
||||
scope.stop()
|
||||
expect(requests[1]?.signal.aborted).toBe(true)
|
||||
})
|
||||
it('retains the prior snapshot on failed manual refresh and exposes the error', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValueOnce('snapshot').mockRejectedValueOnce(new Error('database timeout'))
|
||||
const scope = effectScope()
|
||||
const state = scope.run(() => useOverviewRequest(ref(1), fetcher))!
|
||||
await Promise.resolve()
|
||||
await state.refresh()
|
||||
expect(state.data.value).toBe('snapshot')
|
||||
expect(state.error.value).toBe('database timeout')
|
||||
scope.stop()
|
||||
})
|
||||
it('retains revision refresh snapshots but clears data when the filter scope changes', async () => {
|
||||
const revision = ref(0)
|
||||
const filter = ref('provider-a')
|
||||
const fetcher = vi.fn().mockResolvedValueOnce('provider-a snapshot').mockRejectedValue(new Error('database timeout'))
|
||||
const scope = effectScope()
|
||||
const state = scope.run(() => useOverviewRequest(() => [filter.value, revision.value], fetcher, { scopeKey: filter }))!
|
||||
await Promise.resolve()
|
||||
revision.value += 1
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
expect(state.data.value).toBe('provider-a snapshot')
|
||||
expect(state.error.value).toBe('database timeout')
|
||||
filter.value = 'provider-b'
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
expect(state.data.value).toBeNull()
|
||||
expect(state.error.value).toBe('database timeout')
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createApp, h, nextTick, ref, type App, type VNode } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OverviewMetrics } from '@/api/overview'
|
||||
import ConsumptionTable from '../users/ConsumptionTable.vue'
|
||||
import PeriodComparison from '../dashboard/PeriodComparison.vue'
|
||||
import HealthSummary from '../dashboard/HealthSummary.vue'
|
||||
|
||||
const api = vi.hoisted(() => ({ consumption: vi.fn(), summary: vi.fn(), health: vi.fn() }))
|
||||
vi.mock('@/api/overview', () => ({ overviewApi: api }))
|
||||
vi.mock('@/api/endpoints/health-v2', () => ({ getAdminHealthSummary: api.health }))
|
||||
|
||||
const amount = { value: '10', currency: 'USD', basis: 'billable', status: 'known' }
|
||||
const metrics = (requests: number): OverviewMetrics => ({
|
||||
request_count: requests, successful_request_count: requests, failed_request_count: 0,
|
||||
cancelled_request_count: 0, in_flight_request_count: 0, unclassified_failure_count: 0,
|
||||
input_tokens: requests, output_tokens: requests, total_tokens: requests * 2, usage_active_users: 1,
|
||||
success_rate: { value: 1, numerator: requests, denominator: requests },
|
||||
latency_ms: { avg: 100, p50: 100, p95: 100, p99: 100, sample_count: requests },
|
||||
rated_amount: amount, billable_amount: amount, quota_covered_amount: amount, wallet_consumed_amount: amount, wallet_debit_amount: amount,
|
||||
})
|
||||
const range = { from: '2026-09-10T00:00:00.000Z', to: '2026-09-11T00:00:00.000Z', timezone: 'UTC' }
|
||||
const meta = {
|
||||
range, generated_at: '2026-09-11T00:00:00Z', data_through: null,
|
||||
coverage: { status: 'complete', request_count: 10, usage_available_count: 10, pricing_available_count: 10, settled_count: 10 },
|
||||
}
|
||||
const mounted: App[] = []
|
||||
async function settle() { await Promise.resolve(); await nextTick(); await new Promise(resolve => setTimeout(resolve, 0)) }
|
||||
async function mount(render: () => VNode) {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/admin/user-stats/:userId', component: { render: () => null } },
|
||||
{ path: '/admin/usage', component: { render: () => null } },
|
||||
{ path: '/admin/health-monitor', component: { render: () => null } },
|
||||
] })
|
||||
await router.push({ path: '/admin/user-stats/member-a', query: range })
|
||||
const root = document.createElement('div')
|
||||
const app = createApp({ render }).use(router)
|
||||
app.mount(root)
|
||||
mounted.push(app)
|
||||
await settle()
|
||||
return root
|
||||
}
|
||||
beforeEach(() => vi.resetAllMocks())
|
||||
afterEach(() => { for (const app of mounted.splice(0)) app.unmount() })
|
||||
|
||||
describe('revision-driven overview snapshots', () => {
|
||||
it('retains consumption while refreshing or after failure but clears another member’s data', async () => {
|
||||
api.consumption.mockResolvedValue({ meta, data: { total: 1, limit: 25, offset: 0, items: [{
|
||||
id: 'usage-a', request_id: 'request-a', started_at: range.from, user_id: 'member-a', model: 'model-a',
|
||||
provider: 'provider-a', status: 'success', settlement_status: 'settled', attribution_kind: 'employee',
|
||||
rated_amount: amount, billable_amount: amount, quota_covered_amount: amount, wallet_consumed_amount: amount, wallet_debit_amount: amount,
|
||||
}] } })
|
||||
const revision = ref(0)
|
||||
const userId = ref('member-a')
|
||||
const root = await mount(() => h(ConsumptionTable, { userId: userId.value, revision: revision.value }))
|
||||
expect(root.textContent).toContain('request-a')
|
||||
let rejectRefresh!: (reason: Error) => void
|
||||
api.consumption.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectRefresh = reject }))
|
||||
revision.value += 1
|
||||
await nextTick()
|
||||
expect(root.textContent).toContain('request-a')
|
||||
rejectRefresh(new Error('consumption unavailable'))
|
||||
await settle()
|
||||
expect(root.textContent).toContain('request-a')
|
||||
expect(root.querySelector('[role="alert"]')?.textContent).toContain('consumption unavailable')
|
||||
api.consumption.mockRejectedValue(new Error('member lookup unavailable'))
|
||||
userId.value = 'member-b'
|
||||
await settle()
|
||||
expect(root.textContent).not.toContain('request-a')
|
||||
expect(api.consumption.mock.lastCall?.[0].user_id).toBe('member-b')
|
||||
})
|
||||
|
||||
it('retains comparison values after failed refresh but clears them for a changed period', async () => {
|
||||
api.summary.mockResolvedValue({ meta, data: metrics(10) })
|
||||
const revision = ref(0)
|
||||
const selectedRange = ref(range)
|
||||
const root = await mount(() => h(PeriodComparison, { current: metrics(20), range: selectedRange.value, revision: revision.value }))
|
||||
expect(root.querySelector('dl')?.textContent).toContain('+100.0%')
|
||||
api.summary.mockRejectedValue(new Error('summary unavailable'))
|
||||
revision.value += 1
|
||||
await settle()
|
||||
expect(root.querySelector('dl')?.textContent).toContain('+100.0%')
|
||||
expect(root.querySelector('[role="alert"]')).not.toBeNull()
|
||||
selectedRange.value = { ...range, from: '2026-09-09T00:00:00.000Z' }
|
||||
await settle()
|
||||
expect(root.querySelector('dl')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps health metrics visible beside a failed revision refresh', async () => {
|
||||
api.health.mockResolvedValue({ meta: { generated_at: meta.generated_at, freshness: 'current' }, data: {
|
||||
status: 'healthy', requests: { service_availability: { value: 1 } },
|
||||
degraded_count: 0, unavailable_count: 0, unknown_count: 0, object_count: 2,
|
||||
} })
|
||||
const revision = ref(0)
|
||||
const root = await mount(() => h(HealthSummary, { revision: revision.value }))
|
||||
expect(root.querySelector('dl')?.textContent).toContain('100.00%')
|
||||
api.health.mockRejectedValue(new Error('health unavailable'))
|
||||
revision.value += 1
|
||||
await settle()
|
||||
expect(root.querySelector('dl')?.textContent).toContain('100.00%')
|
||||
expect(root.querySelector('[role="alert"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createApp, nextTick, type App } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import RuntimeFocus from '../operations/RuntimeFocus.vue'
|
||||
import { buildGatewayMetricsSummary } from '@/api/monitoring'
|
||||
import type { OverviewLive } from '@/api/overview'
|
||||
import { setI18nLocale } from '@/i18n'
|
||||
|
||||
let app: App | undefined
|
||||
afterEach(() => app?.unmount())
|
||||
|
||||
function snapshot(): OverviewLive {
|
||||
return {
|
||||
observed_at: '2026-09-20T00:00:00Z', window_seconds: 60, node_id: 'test-node',
|
||||
scope: { kind: 'node' }, metrics: buildGatewayMetricsSummary(''), resilience: null,
|
||||
unavailable_sections: [],
|
||||
}
|
||||
}
|
||||
|
||||
async function mount(value: OverviewLive) {
|
||||
setI18nLocale('zh-CN')
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: { render: () => null } }] })
|
||||
await router.push('/admin/operations')
|
||||
await router.isReady()
|
||||
const root = document.createElement('div')
|
||||
app = createApp(RuntimeFocus, { snapshot: value })
|
||||
app.use(router)
|
||||
app.mount(root)
|
||||
await nextTick()
|
||||
return root
|
||||
}
|
||||
|
||||
function valueOf(root: HTMLElement, key: string) {
|
||||
return root.querySelector(`[data-runtime-focus-item="${key}"] dd`)?.textContent
|
||||
}
|
||||
|
||||
describe('operations processing pipeline', () => {
|
||||
it('keeps unavailable samples distinct from measured zeroes', async () => {
|
||||
const sample = snapshot()
|
||||
sample.metrics!.requestCandidateQueue.depth = 0
|
||||
sample.metrics!.postgres.waitingConnections = 0
|
||||
const root = await mount(sample)
|
||||
expect(root.querySelectorAll('[data-runtime-focus-group]')).toHaveLength(3)
|
||||
expect(valueOf(root, 'candidate-queue')).toBe('0')
|
||||
expect(valueOf(root, 'database-waiting')).toBe('0')
|
||||
expect(valueOf(root, 'lock-waiting')).toBe('-')
|
||||
expect(valueOf(root, 'open-circuits')).toBe('-')
|
||||
expect(valueOf(root, 'usage-lag')).toBe('-')
|
||||
expect(root.querySelector('[data-runtime-focus-queue-state]')?.textContent).toBe('未采集')
|
||||
expect(root.textContent).not.toContain('正常')
|
||||
})
|
||||
|
||||
it('separates undelivered, unacknowledged and dead-letter entries, preserving a real zero', async () => {
|
||||
const sample = snapshot()
|
||||
Object.assign(sample.metrics!.usageQueue, { enabled: true, configured: true, unavailable: false, groupLag: 7, groupPending: 0, dlqLength: 3 })
|
||||
sample.resilience = {
|
||||
timestamp: '2026-09-20T00:00:00Z', health_score: 75, status: 'degraded', recent_errors: [], recommendations: [],
|
||||
error_statistics: { total_errors: 9, active_keys: 4, degraded_keys: 1, unhealthy_keys: 0, open_circuit_breakers: 2, circuit_breakers: {} },
|
||||
}
|
||||
const root = await mount(sample)
|
||||
const usage = root.querySelector('[data-runtime-focus-group="usage"]')!
|
||||
expect([...usage.querySelectorAll('dt')].map(item => item.textContent)).toEqual(['待消费', '待确认', '死信'])
|
||||
expect([...usage.querySelectorAll('dd')].map(item => item.textContent)).toEqual(['7', '0', '3'])
|
||||
expect(usage.querySelector('[data-runtime-focus-queue-state]')).toBeNull()
|
||||
expect(valueOf(root, 'open-circuits')).toBe('2')
|
||||
expect(valueOf(root, 'degraded-credentials')).toBe('1')
|
||||
expect(root.querySelector('a')?.getAttribute('href')).toContain('/admin/health-monitor')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ enabled: false }, '未启用'],
|
||||
[{ configured: false }, '未配置'],
|
||||
[{ unavailable: true }, '未采集'],
|
||||
])('does not present queue defaults as measurements when %s', async (flags, label) => {
|
||||
const sample = snapshot()
|
||||
Object.assign(sample.metrics!.usageQueue, { enabled: true, configured: true, unavailable: false, groupLag: 0, groupPending: 0, dlqLength: 0 }, flags)
|
||||
const root = await mount(sample)
|
||||
expect(root.querySelector('[data-runtime-focus-queue-state]')?.textContent).toBe(label)
|
||||
for (const key of ['usage-lag', 'usage-pending', 'usage-dead-letters']) expect(valueOf(root, key)).toBe('-')
|
||||
})
|
||||
|
||||
it('uses the supplied Prometheus snapshot when structured metrics are absent', async () => {
|
||||
const sample = snapshot()
|
||||
sample.metrics = null
|
||||
sample.metrics_text = 'usage_queue_group_lag 4\nusage_queue_group_pending 2\nusage_queue_dlq_length 0\n'
|
||||
const root = await mount(sample)
|
||||
expect(valueOf(root, 'usage-lag')).toBe('4')
|
||||
expect(valueOf(root, 'usage-pending')).toBe('2')
|
||||
expect(valueOf(root, 'usage-dead-letters')).toBe('0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createApp, nextTick, type App } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import RuntimeView from '../operations/RuntimeView.vue'
|
||||
import { setI18nLocale } from '@/i18n'
|
||||
|
||||
const api = vi.hoisted(() => ({ live: vi.fn() }))
|
||||
vi.mock('@/api/overview', () => ({ overviewApi: api }))
|
||||
vi.mock('../components/OverviewStatus.vue', () => ({ default: { render: () => null } }))
|
||||
|
||||
let app: App | undefined
|
||||
afterEach(() => { app?.unmount(); vi.resetAllMocks() })
|
||||
async function mount() {
|
||||
setI18nLocale('zh-CN')
|
||||
const root = document.createElement('div')
|
||||
app = createApp(RuntimeView, { revision: 0 })
|
||||
app.mount(root)
|
||||
for (let index = 0; index < 5; index += 1) { await Promise.resolve(); await nextTick() }
|
||||
return root
|
||||
}
|
||||
function metric(root: HTMLElement, label: string) { return root.querySelector(`[data-operations-metric="${label}"]`) }
|
||||
|
||||
describe('operations live throughput', () => {
|
||||
it('shows recent throughput and current-node concurrency from a single live snapshot', async () => {
|
||||
api.live.mockResolvedValue({ data: {
|
||||
metrics: {
|
||||
local: { inFlight: 8 }, tunnel: { activeStreams: 3 },
|
||||
process: {
|
||||
systemCpuUsageBasisPoints: 2640, systemMemoryUsageBasisPoints: 3750,
|
||||
processCpuUsageBasisPoints: 25000, processMemoryBytes: 512 * 1024 ** 2,
|
||||
systemMemoryUsedBytes: 6 * 1024 ** 3, systemMemoryTotalBytes: 16 * 1024 ** 3,
|
||||
},
|
||||
},
|
||||
recent_activity: { meta: {}, data: {
|
||||
requests_per_minute: 240, tokens_per_minute: 12000,
|
||||
requests_per_second: 4, request_count: 240,
|
||||
success_rate: { value: 0.95 }, failed_request_count: 12,
|
||||
} },
|
||||
} })
|
||||
const root = await mount()
|
||||
expect(root.querySelectorAll('[data-operations-hardware] [data-operations-metric]')).toHaveLength(4)
|
||||
expect(root.querySelectorAll('[data-operations-gateway] [data-operations-metric]')).toHaveLength(6)
|
||||
expect(metric(root, '主机 CPU')?.textContent).toContain('26.4%')
|
||||
expect(metric(root, '主机内存')?.textContent).toContain('37.5%')
|
||||
expect(metric(root, '主机内存')?.textContent).toContain('6 GiB / 16 GiB')
|
||||
expect(metric(root, '进程 CPU')?.textContent).toContain('250%')
|
||||
expect(metric(root, '进程内存')?.textContent).toContain('512 MiB')
|
||||
expect(metric(root, 'RPM')?.textContent).toContain('240')
|
||||
expect(metric(root, 'TPM')?.textContent).toContain('12,000')
|
||||
expect(metric(root, '请求成功率')?.textContent).toContain('95.00%')
|
||||
expect(metric(root, '失败请求')?.textContent).toContain('12')
|
||||
expect(metric(root, '当前并发')?.textContent).toContain('8')
|
||||
expect(metric(root, '活跃流')?.textContent).toContain('3')
|
||||
expect(metric(root, 'RPM')?.textContent).toContain('最近 60 秒')
|
||||
expect(metric(root, '当前并发')?.textContent).toContain('当前节点')
|
||||
expect(api.live).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps unavailable values distinct from measured zeroes', async () => {
|
||||
api.live.mockResolvedValue({ data: {
|
||||
metrics: { process: { systemCpuUsageBasisPoints: 0, systemMemoryTotalBytes: 0, systemMemoryUsageBasisPoints: 0 }, local: {}, tunnel: {} },
|
||||
recent_activity: { data: { requests_per_minute: 0, tokens_per_minute: null, success_rate: { value: null }, failed_request_count: 0 } },
|
||||
} })
|
||||
const root = await mount()
|
||||
expect(metric(root, 'RPM')?.querySelector('span[title]')?.textContent).toBe('0')
|
||||
expect(metric(root, 'TPM')?.querySelector('span[title]')?.textContent).toBe('-')
|
||||
expect(metric(root, '请求成功率')?.querySelector('span[title]')?.textContent).toBe('-')
|
||||
expect(metric(root, '当前并发')?.querySelector('span[title]')?.textContent).toBe('-')
|
||||
expect(metric(root, '活跃流')?.querySelector('span[title]')?.textContent).toBe('-')
|
||||
expect(metric(root, '主机 CPU')?.querySelector('span[title]')?.textContent).toBe('0%')
|
||||
for (const label of ['主机内存', '进程 CPU', '进程内存']) {
|
||||
expect(metric(root, label)?.querySelector('span[title]')?.textContent).toBe('-')
|
||||
}
|
||||
expect(api.live).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,244 @@
|
||||
import { createApp, nextTick, type Component } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import UserStats from '@/views/admin/UserStats.vue'
|
||||
import UserAnalysisDetail from '@/views/admin/UserAnalysisDetail.vue'
|
||||
import { setI18nLocale } from '@/i18n'
|
||||
|
||||
const api = vi.hoisted(() => ({ users: vi.fn(), user: vi.fn(), timeseries: vi.fn(), breakdown: vi.fn(), consumption: vi.fn(), exportCsv: vi.fn() }))
|
||||
const accountApi = vi.hoisted(() => ({ wallets: vi.fn(), transactions: vi.fn(), plans: vi.fn() }))
|
||||
vi.mock('@/api/overview', () => ({ overviewApi: api }))
|
||||
vi.mock('@/api/admin-wallets', () => ({ adminWalletApi: { listWallets: accountApi.wallets, getWalletTransactions: accountApi.transactions } }))
|
||||
vi.mock('@/api/users', () => ({ usersApi: { listUserPlanEntitlements: accountApi.plans } }))
|
||||
vi.mock('@/components/charts/BarChart.vue', () => ({ default: { render: () => null } }))
|
||||
vi.mock('@/components/charts/LineChart.vue', () => ({ default: { render: () => null } }))
|
||||
const range = 'from=2026-09-01T00:00:00Z&to=2026-09-02T00:00:00Z&timezone=UTC'
|
||||
const meta = {
|
||||
schema_version: 1, metric_version: 'overview-v2', scope: { kind: 'installation' },
|
||||
range: { from: '2026-09-01T00:00:00Z', to: '2026-09-02T00:00:00Z', timezone: 'UTC', time_basis: 'request_started_at' },
|
||||
generated_at: '2026-09-02T00:01:00Z', data_through: '2026-09-02T00:00:00Z', read_revision: 'test-1',
|
||||
coverage: { status: 'complete', request_count: 1, usage_available_count: 1, pricing_available_count: 1, settled_count: 1, attribution_available_count: 1 },
|
||||
}
|
||||
const amount = (value: string | null) => ({ value, currency: 'USD', basis: 'billable', status: value == null ? 'unknown' : 'known' })
|
||||
const metrics = { request_count: 120, successful_request_count: 118, failed_request_count: 2, total_tokens: 8800, billable_amount: amount('432.25'), quota_covered_amount: amount('300'), wallet_debit_amount: amount('132.25') }
|
||||
const finance = { wallet_balance: amount('765.50'), recharge_balance: amount('700'), gift_balance: amount('65.5'), recharge_amount: amount('1000'), recharge_count: 4, plan_purchase_amount: amount('99'), plan_purchase_count: 1, gift_credit_amount: amount('20'), gift_credit_count: 1, balance_time_basis: 'current', payment_time_basis: 'credited_at' }
|
||||
const employee = { user_id: 'employee-0', username: 'Zero Usage Employee', email: '[email protected]', is_active: true, last_used_at: null, active_days: 0, request_count: 0, total_tokens: 0, billable_amount: amount('0'), finance }
|
||||
const wallet = { id: 'wallet-0', user_id: employee.user_id, owner_type: 'user', balance: 765.5, recharge_balance: 700, gift_balance: 65.5, currency: 'USD' }
|
||||
const transaction = { id: 'tx-0', category: 'adjust', reason_code: 'adjust_admin', amount: -10, balance_before: 775.5, balance_after: 765.5, recharge_balance_before: 710, recharge_balance_after: 700, gift_balance_before: 65.5, gift_balance_after: 65.5, description: 'balance correction', created_at: '2026-08-01T08:00:00Z' }
|
||||
const plan = { id: 'plan-history-0', user_id: employee.user_id, plan_id: 'plan-0', plan_title: 'Old monthly plan', payment_order_id: 'order-0', status: 'revoked', active: false, starts_at: '2026-07-01T00:00:00Z', expires_at: '2026-08-01T00:00:00Z', updated_at: '2026-08-01T00:00:00Z', entitlements: [{ type: 'daily_quota', daily_quota_usd: 20 }] }
|
||||
const cleanup: (() => void)[] = []
|
||||
async function settle() { await Promise.resolve(); await Promise.resolve(); await nextTick(); await new Promise(resolve => setTimeout(resolve, 0)) }
|
||||
async function mount(component: Component, path: string) {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/admin/user-stats', component: UserStats }, { path: '/admin/user-stats/:userId', component: UserAnalysisDetail }, { path: '/admin/usage', component: { render: () => null } }] })
|
||||
await router.push(path)
|
||||
const root = document.createElement('div')
|
||||
const app = createApp(component).use(router)
|
||||
app.mount(root)
|
||||
cleanup.push(() => app.unmount())
|
||||
await settle()
|
||||
return { root, router }
|
||||
}
|
||||
function button(root: Element, label: string) {
|
||||
const result = [...root.querySelectorAll<HTMLButtonElement>('button')].find(item => item.textContent?.includes(label) || item.getAttribute('aria-label')?.includes(label))
|
||||
if (!result) throw new Error(`Missing button: ${label}`)
|
||||
return result
|
||||
}
|
||||
function section(root: Element, selector: string) {
|
||||
const result = root.querySelector(selector)
|
||||
if (!result) throw new Error(`Missing section: ${selector}`)
|
||||
return result
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setI18nLocale('zh-CN')
|
||||
api.users.mockImplementation(async query => ({ meta, data: { total: 62, limit: query.limit, offset: query.offset, items: [employee], summary: { ...metrics, user_count: 62, active_user_count: 7 }, finance_summary: finance } }))
|
||||
api.user.mockImplementation(async (id, query) => ({ meta, data: { user: { id, username: id, email: '[email protected]', is_active: true }, summary: metrics, finance, payments: { items: [{ id: 'payment-1', order_no: 'credited-order', kind: 'wallet_recharge', amount: amount('1000'), payment_method: 'alipay', credited_at: '2026-09-01T01:00:00Z' }], total: 80, limit: query.payment_limit, offset: query.payment_offset } } }))
|
||||
api.timeseries.mockResolvedValue({ meta, data: { items: [], granularity: 'hour' } })
|
||||
api.breakdown.mockResolvedValue({ meta, data: { items: [], total: 0, limit: 10, offset: 0 } })
|
||||
api.consumption.mockImplementation(async query => ({ meta, data: { items: [{ id: 'usage-1', request_id: 'request-1', started_at: '2026-09-01T02:00:00Z', model: 'test-model', provider: 'provider', status: 'success', settlement_status: 'settled', billable_amount: amount('10'), quota_covered_amount: amount('8'), wallet_debit_amount: amount('2') }], total: 75, limit: query.limit, offset: query.offset } }))
|
||||
accountApi.wallets.mockResolvedValue({ items: [wallet], total: 1, limit: 1, offset: 0 })
|
||||
accountApi.transactions.mockImplementation(async (_id, query) => ({ wallet, items: [transaction], total: 60, ...query }))
|
||||
accountApi.plans.mockResolvedValue({ items: [plan], total: 1 })
|
||||
})
|
||||
afterEach(() => { cleanup.splice(0).forEach(fn => fn()); vi.useRealTimers(); setI18nLocale('zh-CN') })
|
||||
|
||||
describe('enterprise user accounts', () => {
|
||||
it('shows full-roster financial totals, keeps zero-use users and delegates sorting/pagination', async () => {
|
||||
const { root } = await mount(UserStats, `/admin/user-stats?${range}&attribution_kind=standalone&model=legacy`)
|
||||
expect(root.textContent).toContain('Zero Usage Employee')
|
||||
expect(section(root, '[data-user-summary="consumption"]').textContent).toContain('432.25')
|
||||
expect(section(root, '[data-user-summary="activity"]').textContent).toMatch(/7\s*\/ 62/)
|
||||
expect(root.querySelector('select[aria-label="归属"]')).toBeNull()
|
||||
expect(root.querySelector('[role="tablist"]')).toBeNull()
|
||||
expect(api.users.mock.lastCall?.[0]).toMatchObject({ sort: 'billable_amount', order: 'desc', limit: 25, offset: 0 })
|
||||
expect(api.users.mock.lastCall?.[0]).not.toHaveProperty('attribution_kind')
|
||||
expect(api.users.mock.lastCall?.[0]).not.toHaveProperty('model')
|
||||
button(root, '第 2 页').click()
|
||||
await settle()
|
||||
expect(api.users.mock.lastCall?.[0]).toMatchObject({ offset: 25 })
|
||||
button(root, 'Tokens').click()
|
||||
await settle()
|
||||
expect(api.users.mock.lastCall?.[0]).toMatchObject({ offset: 0, sort: 'total_tokens', order: 'desc' })
|
||||
expect(root.querySelector('a[href*="employee-0"]')).toBeNull()
|
||||
expect(root.querySelector('a[href*="/admin/usage"]')).toBeNull()
|
||||
expect([...section(root, '[data-user-accounts]').querySelectorAll('a, button')].some(item => item.textContent?.trim() === '使用记录')).toBe(false)
|
||||
})
|
||||
it('places reports first and opens reusable account history without leaving the page', async () => {
|
||||
const { root, router } = await mount(UserStats, `/admin/user-stats?${range}`)
|
||||
const reports = section(root, '[data-user-reports]')
|
||||
const accounts = section(root, '[data-user-accounts]')
|
||||
expect(reports.compareDocumentPosition(accounts) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(accountApi.wallets).not.toHaveBeenCalled()
|
||||
expect(accountApi.plans).not.toHaveBeenCalled()
|
||||
const before = router.currentRoute.value.fullPath
|
||||
const accountButton = button(accounts, '账目')
|
||||
expect(accountButton.textContent?.trim()).toBe('')
|
||||
expect(accountButton.querySelector('svg')).not.toBeNull()
|
||||
accountButton.click()
|
||||
await settle()
|
||||
await settle()
|
||||
const dialog = section(document.body, '[data-user-account-drawer]')
|
||||
expect(router.currentRoute.value.fullPath).toBe(before)
|
||||
expect(accountApi.wallets).toHaveBeenCalledWith({ user_id: employee.user_id, owner_type: 'user', limit: 1, offset: 0 }, expect.any(AbortSignal))
|
||||
expect(accountApi.plans).toHaveBeenCalledWith(employee.user_id, { include_inactive: true }, expect.any(AbortSignal))
|
||||
expect(dialog.textContent).toContain('balance correction')
|
||||
expect(dialog.textContent).toContain('775.5000 → 765.5000')
|
||||
expect(dialog.textContent).toContain('Old monthly plan')
|
||||
expect(dialog.textContent).toContain('已撤销')
|
||||
expect(dialog.textContent).toContain('每日额度')
|
||||
expect(dialog.textContent).not.toContain('撤销套餐')
|
||||
expect(dialog.textContent).not.toContain('发放套餐')
|
||||
button(dialog, '第 2 页').click()
|
||||
await settle()
|
||||
expect(accountApi.transactions.mock.lastCall?.slice(0, 2)).toEqual(['wallet-0', { limit: 25, offset: 25 }])
|
||||
expect(accountApi.plans).toHaveBeenCalledTimes(1)
|
||||
expect(api.users).toHaveBeenCalledTimes(1)
|
||||
expect(api.timeseries).toHaveBeenCalledTimes(1)
|
||||
document.body.querySelector<HTMLButtonElement>('[aria-label="关闭账目"]')?.click()
|
||||
await settle()
|
||||
expect(document.querySelector('[data-user-account-drawer]')).toBeNull()
|
||||
})
|
||||
it('keeps plan history available without a wallet and retries failed wallet lookup', async () => {
|
||||
accountApi.wallets.mockRejectedValueOnce(new Error('wallet unavailable')).mockResolvedValue({ items: [], total: 0 })
|
||||
const { root } = await mount(UserStats, `/admin/user-stats?${range}`)
|
||||
button(section(root, '[data-user-accounts]'), '账目').click()
|
||||
await settle()
|
||||
const dialog = section(document.body, '[data-user-account-drawer]')
|
||||
expect(dialog.textContent).toContain('wallet unavailable')
|
||||
expect(dialog.textContent).toContain('Old monthly plan')
|
||||
button(dialog, '重试').click()
|
||||
await settle()
|
||||
expect(dialog.textContent).toContain('该用户尚无钱包')
|
||||
expect(accountApi.transactions).not.toHaveBeenCalled()
|
||||
})
|
||||
it('discards closed account requests before opening another user', async () => {
|
||||
let resolveOld: (value: unknown) => void = () => {}
|
||||
accountApi.wallets.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
const second = { ...employee, user_id: 'employee-1', username: 'Second employee' }
|
||||
api.users.mockResolvedValue({ meta, data: { total: 2, limit: 25, offset: 0, items: [employee, second], summary: metrics, finance_summary: finance } })
|
||||
const { root } = await mount(UserStats, `/admin/user-stats?${range}`)
|
||||
const accounts = section(root, '[data-user-accounts]')
|
||||
button(accounts, employee.username).click()
|
||||
await settle()
|
||||
const oldSignal = accountApi.wallets.mock.lastCall?.[1] as AbortSignal
|
||||
document.body.querySelector<HTMLButtonElement>('[aria-label="关闭账目"]')?.click()
|
||||
await settle()
|
||||
expect(oldSignal.aborted).toBe(true)
|
||||
accountApi.wallets.mockResolvedValue({ items: [{ ...wallet, id: 'wallet-1', user_id: second.user_id }], total: 1 })
|
||||
accountApi.transactions.mockResolvedValue({ wallet: { ...wallet, id: 'wallet-1', user_id: second.user_id }, items: [{ ...transaction, description: 'second account only' }], total: 1 })
|
||||
button(accounts, second.username).click()
|
||||
await settle()
|
||||
resolveOld({ items: [wallet], total: 1 })
|
||||
await settle()
|
||||
const dialog = section(document.body, '[data-user-account-drawer]')
|
||||
expect(dialog.textContent).toContain('second account only')
|
||||
expect(accountApi.transactions.mock.calls.every(call => call[0] === 'wallet-1')).toBe(true)
|
||||
})
|
||||
it('loads accounts independently of pending and failed reports', async () => {
|
||||
api.timeseries.mockImplementation(() => new Promise(() => {}))
|
||||
api.breakdown.mockRejectedValue(new Error('models unavailable'))
|
||||
const { root } = await mount(UserStats, `/admin/user-stats?${range}`)
|
||||
expect(root.textContent).toContain('Zero Usage Employee')
|
||||
expect(section(root, '[data-user-summary="recharge"]').textContent).toContain('1,000.00')
|
||||
expect(section(root, '[data-user-report="models"]').textContent).toContain('模型报表暂不可用')
|
||||
expect(root.textContent).toContain('全站用量')
|
||||
expect(root.textContent).not.toContain('全体用户')
|
||||
})
|
||||
it('keeps missing finance unknown instead of showing a zero balance', async () => {
|
||||
api.users.mockResolvedValue({ meta, data: { total: 1, limit: 25, offset: 0, items: [{ ...employee, finance: null }], summary: { ...metrics, user_count: 1, active_user_count: 0 }, finance_summary: null } })
|
||||
const { root } = await mount(UserStats, `/admin/user-stats?${range}`)
|
||||
expect(section(root, '[data-user-summary="balance"]').textContent).toMatch(/余额\s*-/)
|
||||
expect(section(root, '[data-user-summary="balance"]').textContent).not.toContain('$0')
|
||||
})
|
||||
it('searches account totals without refetching the installation reports', async () => {
|
||||
const { root } = await mount(UserStats, `/admin/user-stats?${range}`)
|
||||
const input = root.querySelector<HTMLInputElement>('input[aria-label="搜索用户"]')
|
||||
if (!input) throw new Error('Missing user search')
|
||||
input.value = 'Zero'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
input.closest('form')?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||
await settle()
|
||||
expect(api.users.mock.lastCall?.[0]).toMatchObject({ search: 'Zero', offset: 0 })
|
||||
expect(api.timeseries).toHaveBeenCalledTimes(1)
|
||||
expect(api.breakdown).toHaveBeenCalledTimes(1)
|
||||
expect(root.textContent).toContain('上方汇总与账目仅包含匹配的用户')
|
||||
})
|
||||
it('shows all sections and paginates payments independently from usage', async () => {
|
||||
const { root } = await mount(UserAnalysisDetail, `/admin/user-stats/member?${range}&tab=usage&model=legacy`)
|
||||
expect(root.querySelector('[role="tablist"]')).toBeNull()
|
||||
expect(root.textContent).toContain('credited-order')
|
||||
expect(root.textContent).toContain('request-1')
|
||||
expect(section(root, '[data-user-consumption]').textContent).not.toContain('规则计价')
|
||||
expect(section(root, '[data-user-consumption]').textContent).toContain('额度抵扣')
|
||||
expect(api.user.mock.lastCall?.[1]).toMatchObject({ user_id: 'member', payment_limit: 25, payment_offset: 0 })
|
||||
expect(api.consumption.mock.lastCall?.[0]).not.toHaveProperty('model')
|
||||
section(root, '[data-user-payments]').querySelector<HTMLButtonElement>('[aria-label="下一页"]')?.click()
|
||||
await settle()
|
||||
expect(api.user.mock.lastCall?.[1]).toMatchObject({ payment_offset: 25 })
|
||||
expect(api.consumption).toHaveBeenCalledTimes(1)
|
||||
expect(api.timeseries).toHaveBeenCalledTimes(1)
|
||||
section(root, '[data-user-consumption]').querySelector<HTMLButtonElement>('[aria-label="下一页"]')?.click()
|
||||
await settle()
|
||||
expect(api.consumption.mock.lastCall?.[0]).toMatchObject({ offset: 25 })
|
||||
expect(api.user).toHaveBeenCalledTimes(2)
|
||||
expect(api.breakdown.mock.lastCall?.[0]).toMatchObject({ user_id: 'member', group_by: 'model', sort: 'total_tokens' })
|
||||
})
|
||||
it('clears money and resets payment pagination when the selected period changes', async () => {
|
||||
const { root, router } = await mount(UserAnalysisDetail, `/admin/user-stats/member?${range}`)
|
||||
section(root, '[data-user-payments]').querySelector<HTMLButtonElement>('[aria-label="下一页"]')?.click()
|
||||
await settle()
|
||||
api.user.mockImplementation(() => new Promise(() => {}))
|
||||
await router.replace('/admin/user-stats/member?from=2026-08-01T00:00:00Z&to=2026-08-02T00:00:00Z&timezone=UTC')
|
||||
await settle()
|
||||
expect(api.user.mock.lastCall?.[1]).toMatchObject({ payment_offset: 0, from: '2026-08-01T00:00:00.000Z' })
|
||||
expect(root.textContent).not.toContain('credited-order')
|
||||
expect(section(root, '[data-user-summary="consumption"]').textContent).not.toContain('432.25')
|
||||
})
|
||||
it('refreshes each section once on activation and every 10 seconds, then stops', async () => {
|
||||
const { root } = await mount(UserStats, '/admin/user-stats?relative_preset=today&timezone=UTC')
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
|
||||
root.querySelector<HTMLButtonElement>('[aria-label="开启自动刷新(每 10 秒)"]')?.click()
|
||||
await nextTick()
|
||||
expect(api.users).toHaveBeenCalledTimes(2)
|
||||
expect(api.timeseries).toHaveBeenCalledTimes(2)
|
||||
expect(api.breakdown).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(api.users).toHaveBeenCalledTimes(3)
|
||||
expect(api.timeseries).toHaveBeenCalledTimes(3)
|
||||
expect(api.breakdown).toHaveBeenCalledTimes(3)
|
||||
root.querySelector<HTMLButtonElement>('[aria-label="关闭自动刷新(每 10 秒)"]')?.click()
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
expect(api.users).toHaveBeenCalledTimes(3)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
it('exports the user report for the selected period and matching user search', async () => {
|
||||
api.exportCsv.mockRejectedValue(new Error('export check'))
|
||||
const { root } = await mount(UserStats, `/admin/user-stats?${range}&search=alice&offset=25`)
|
||||
button(root, '导出用户报表').click()
|
||||
await settle()
|
||||
expect(api.exportCsv.mock.lastCall?.[0]).toBe('users')
|
||||
expect(api.exportCsv.mock.lastCall?.[1]).toMatchObject({ search: 'alice', from: '2026-09-01T00:00:00.000Z', to: '2026-09-02T00:00:00.000Z', timezone: 'UTC', sort: 'billable_amount' })
|
||||
expect(root.textContent).toContain('export check')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<span
|
||||
ref="container"
|
||||
class="block min-w-0"
|
||||
:title="value"
|
||||
><span
|
||||
ref="text"
|
||||
class="inline-block whitespace-nowrap"
|
||||
:style="{ fontSize: `${fontSize}px` }"
|
||||
>{{ value }}</span></span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ value: string; maxFontSize?: number }>(), {
|
||||
maxFontSize: 20,
|
||||
})
|
||||
const container = ref<HTMLElement>()
|
||||
const text = ref<HTMLElement>()
|
||||
const fontSize = ref(props.maxFontSize)
|
||||
let observer: ResizeObserver | null = null
|
||||
let frame: number | null = null
|
||||
let mounted = false
|
||||
|
||||
function fit() {
|
||||
frame = null
|
||||
if (!text.value) return
|
||||
const available = container.value?.clientWidth || 0
|
||||
const width = text.value.getBoundingClientRect().width
|
||||
const renderedFontSize = Number.parseFloat(getComputedStyle(text.value).fontSize)
|
||||
if (!available || !width || !Number.isFinite(renderedFontSize)) return
|
||||
|
||||
// Measure at the rendered size so fitting never briefly restores an oversized value.
|
||||
fontSize.value = Math.max(10, Math.min(
|
||||
props.maxFontSize,
|
||||
Math.floor(available * renderedFontSize / width * 100) / 100,
|
||||
))
|
||||
}
|
||||
|
||||
function scheduleFit() {
|
||||
if (mounted && frame === null) frame = requestAnimationFrame(fit)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mounted = true
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
let previousWidth = -1
|
||||
observer = new ResizeObserver(([entry]) => {
|
||||
const width = entry?.contentRect.width
|
||||
if (width === undefined || width === previousWidth) return
|
||||
previousWidth = width
|
||||
scheduleFit()
|
||||
})
|
||||
if (container.value) observer.observe(container.value)
|
||||
}
|
||||
void document.fonts?.ready.then(scheduleFit)
|
||||
document.fonts?.addEventListener('loadingdone', scheduleFit)
|
||||
scheduleFit()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mounted = false
|
||||
observer?.disconnect()
|
||||
if (frame !== null) cancelAnimationFrame(frame)
|
||||
document.fonts?.removeEventListener('loadingdone', scheduleFit)
|
||||
})
|
||||
|
||||
watch(() => [props.value, props.maxFontSize], scheduleFit, { flush: 'post' })
|
||||
</script>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<section class="min-w-0 space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</h2><slot />
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="py-3 text-left font-medium">
|
||||
{{ t('维度', 'Dimension') }}
|
||||
</th><th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('请求数', 'Requests') }}
|
||||
</th><th class="px-3 py-3 text-right font-medium">
|
||||
Tokens
|
||||
</th><th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('请求成功率', 'Request success') }}
|
||||
</th><th class="py-3 text-right font-medium">
|
||||
{{ t('计费消耗', 'Billable consumption') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.id ?? '__unknown__'"
|
||||
class="border-b last:border-0"
|
||||
>
|
||||
<td class="max-w-64 break-words py-3">
|
||||
<button
|
||||
v-if="item.id"
|
||||
class="text-left hover:text-primary hover:underline"
|
||||
@click="$emit('select', item)"
|
||||
>
|
||||
{{ item.label || item.id || t('未知', 'Unknown') }}
|
||||
</button>
|
||||
<span v-else>{{ item.label || t('未知', 'Unknown') }}</span>
|
||||
</td><td class="whitespace-nowrap px-3 py-3 text-right tabular-nums">
|
||||
{{ count(item.request_count) }}
|
||||
</td><td class="whitespace-nowrap px-3 py-3 text-right tabular-nums">
|
||||
{{ count(item.total_tokens) }}
|
||||
</td><td class="whitespace-nowrap px-3 py-3 text-right tabular-nums">
|
||||
{{ percent(item.success_rate?.value) }}
|
||||
</td><td
|
||||
class="whitespace-nowrap py-3 text-right tabular-nums"
|
||||
:title="item.billable_amount?.value ?? ''"
|
||||
>
|
||||
{{ money(item.billable_amount) }}
|
||||
</td>
|
||||
</tr><tr v-if="!items.length">
|
||||
<td
|
||||
colspan="5"
|
||||
class="py-10 text-center text-muted-foreground"
|
||||
>
|
||||
{{ t('此时间范围暂无数据', 'No data in this range') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OverviewBreakdown } from '@/api/overview'
|
||||
import { count, money, percent } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
defineProps<{ title: string; items: OverviewBreakdown[] }>()
|
||||
defineEmits<{ select: [item: OverviewBreakdown] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isOperations"
|
||||
class="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 xl:grid-cols-6"
|
||||
>
|
||||
<OperationsMetricCard
|
||||
v-for="(item, index) in items"
|
||||
:key="item.label"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
:note="item.note"
|
||||
:icon="metricIcons[index]"
|
||||
/>
|
||||
</div>
|
||||
<dl
|
||||
v-else
|
||||
class="grid grid-cols-2 gap-x-5 gap-y-5 border-y py-5 sm:grid-cols-3 xl:grid-cols-6"
|
||||
>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.label"
|
||||
class="min-w-0"
|
||||
>
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</dt>
|
||||
<dd class="mt-2 font-semibold tabular-nums">
|
||||
<MetricValue :value="item.value" />
|
||||
</dd>
|
||||
<p
|
||||
v-if="item.note"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ item.note }}
|
||||
</p>
|
||||
</div>
|
||||
</dl>
|
||||
<p
|
||||
v-if="metrics.usage_source_counts && mode !== 'cost'"
|
||||
class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<span>{{ t('Token 来源(请求数)', 'Token source (requests)') }}</span>
|
||||
<span
|
||||
v-for="source in tokenSources"
|
||||
:key="source.key"
|
||||
>{{ source.label }} {{ count(metrics.usage_source_counts[source.key]) }}</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Activity, CheckCircle2, Clock, Gauge, Hash, Timer, TriangleAlert, Zap } from 'lucide-vue-next'
|
||||
import type { OverviewMetrics } from '@/api/overview'
|
||||
import { count, money, percent } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import MetricValue from './MetricValue.vue'
|
||||
import OperationsMetricCard from '../operations/OperationsMetricCard.vue'
|
||||
const props = defineProps<{ metrics: OverviewMetrics; mode?: 'global' | 'employee' | 'cost' | 'performance' | 'runtime' }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const isOperations = computed(() => props.mode === 'runtime' || props.mode === 'performance')
|
||||
const metricIcons = computed(() => props.mode === 'performance'
|
||||
? [Activity, CheckCircle2, Clock, Timer, Gauge, Zap]
|
||||
: [Activity, CheckCircle2, Hash, TriangleAlert, Clock, Gauge])
|
||||
const tokenSources = computed(() => [
|
||||
{ key: 'reported' as const, label: t('上游报告', 'Reported') },
|
||||
{ key: 'estimated' as const, label: t('本地估算', 'Estimated') },
|
||||
{ key: 'mixed' as const, label: t('混合', 'Mixed') },
|
||||
{ key: 'unknown' as const, label: t('未知', 'Unknown') },
|
||||
])
|
||||
const items = computed<{ label: string; value: string; note?: string }[]>(() => {
|
||||
const m = props.metrics
|
||||
if (props.mode === 'cost') return [
|
||||
{ label: t('规则计价', 'Rated amount'), value: money(m.rated_amount) },
|
||||
{ label: t('计费消耗', 'Billable consumption'), value: money(m.billable_amount) },
|
||||
{ label: t('额度承担', 'Quota covered'), value: money(m.quota_covered_amount) },
|
||||
{ label: t('钱包消费', 'Wallet consumed'), value: money(m.wallet_consumed_amount) },
|
||||
{ label: t('余额扣减', 'Wallet debited'), value: money(m.wallet_debit_amount) },
|
||||
{ label: t('请求数', 'Requests'), value: count(m.request_count) },
|
||||
]
|
||||
if (props.mode === 'performance') return [
|
||||
{ label: t('请求数', 'Requests'), value: count(m.request_count), note: `${count(m.total_tokens)} Tokens` },
|
||||
{ label: t('请求成功率', 'Request success rate'), value: percent(m.success_rate?.value), note: `${count(m.success_rate?.numerator)}/${count(m.success_rate?.denominator)}` },
|
||||
{ label: t('平均响应 (ms)', 'Avg response (ms)'), value: count(m.latency_ms?.avg) },
|
||||
...(['p50', 'p95', 'p99'] as const).map(key => ({
|
||||
label: `${key.toUpperCase()} (ms)`, value: count(m.latency_ms?.[key]),
|
||||
note: key === 'p99' ? `${count(m.latency_ms?.sample_count)} ${t('样本', 'samples')}` : undefined,
|
||||
})),
|
||||
]
|
||||
if (props.mode === 'runtime') return [
|
||||
{ label: t('请求数', 'Requests'), value: count(m.request_count) },
|
||||
{ label: t('请求成功率', 'Request success rate'), value: percent(m.success_rate?.value), note: `${count(m.success_rate?.numerator)}/${count(m.success_rate?.denominator)}` },
|
||||
{ label: 'Tokens', value: count(m.total_tokens) },
|
||||
{ label: t('失败请求', 'Failed requests'), value: count(m.failed_request_count) },
|
||||
{ label: t('平均响应 (ms)', 'Avg response (ms)'), value: count(m.latency_ms?.avg) },
|
||||
{ label: 'P99 (ms)', value: count(m.latency_ms?.p99), note: `${count(m.latency_ms?.sample_count)} ${t('样本', 'samples')}` },
|
||||
]
|
||||
return [
|
||||
{ label: t('请求数', 'Requests'), value: count(m.request_count) },
|
||||
{ label: t('请求成功率', 'Request success rate'), value: percent(m.success_rate?.value), note: `${count(m.success_rate?.numerator)}/${count(m.success_rate?.denominator)}` },
|
||||
{ label: 'Tokens', value: count(m.total_tokens) },
|
||||
{ label: t('计费消耗', 'Billable consumption'), value: money(m.billable_amount), note: m.billable_amount?.status === 'known_subtotal' ? t('已知小计', 'Known subtotal') : undefined },
|
||||
props.mode === 'employee' ? { label: t('额度承担', 'Quota covered'), value: money(m.quota_covered_amount) } : { label: t('有使用成员', 'Members with usage'), value: count(m.usage_active_users) },
|
||||
props.mode === 'employee' ? { label: t('钱包消费', 'Wallet consumed'), value: money(m.wallet_consumed_amount) } : { label: t('启用成员', 'Enabled members'), value: count(m.enabled_users) },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-t pt-3 text-xs text-muted-foreground">
|
||||
<span>{{ count(total) }} {{ t('条', 'results') }} · {{ total ? count(offset + 1) : 0 }}-{{ count(Math.min(offset + limit, total)) }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
class="h-8 rounded-md border bg-background px-2"
|
||||
:aria-label="t('每页条数', 'Page size')"
|
||||
:value="limit"
|
||||
@change="$emit('size', Number(($event.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option
|
||||
v-for="size in [25, 50, 100]"
|
||||
:key="size"
|
||||
:value="size"
|
||||
>
|
||||
{{ size }}
|
||||
</option>
|
||||
</select>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
class="h-8 w-8"
|
||||
:disabled="loading || offset <= 0"
|
||||
:title="t('上一页', 'Previous page')"
|
||||
:aria-label="t('上一页', 'Previous page')"
|
||||
@click="$emit('page', Math.max(0, offset - limit))"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
class="h-8 w-8"
|
||||
:disabled="loading || offset + limit >= total"
|
||||
:title="t('下一页', 'Next page')"
|
||||
:aria-label="t('下一页', 'Next page')"
|
||||
@click="$emit('page', offset + limit)"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui'
|
||||
import { count } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
defineProps<{ total: number; offset: number; limit: number; loading?: boolean }>()
|
||||
defineEmits<{ page: [offset: number]; size: [limit: number] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="error"
|
||||
role="alert"
|
||||
class="flex flex-wrap items-center justify-between gap-2 border-l-2 border-destructive bg-destructive/5 px-3 py-2 text-sm"
|
||||
>
|
||||
<span class="min-w-0 break-words">{{ t('数据加载失败', 'Unable to load data') }}: {{ error }}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="loading"
|
||||
@click="$emit('retry')"
|
||||
>
|
||||
{{ t('重试', 'Retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="loading && !meta"
|
||||
class="flex min-h-40 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />{{ t('加载中', 'Loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="meta"
|
||||
class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<span :class="partial ? 'text-amber-700 dark:text-amber-400' : ''">{{ partial ? t('部分数据', 'Partial data') : t('数据完整', 'Complete') }}</span>
|
||||
<span>{{ t('计量覆盖', 'Usage coverage') }} {{ count(meta.coverage.usage_available_count) }}/{{ count(meta.coverage.request_count) }}</span>
|
||||
<span>{{ t('定价覆盖', 'Pricing coverage') }} {{ count(meta.coverage.pricing_available_count) }}/{{ count(meta.coverage.request_count) }}</span>
|
||||
<span>{{ t('已结算', 'Settled') }} {{ count(meta.coverage.settled_count) }}/{{ count(meta.coverage.request_count) }}</span>
|
||||
<span
|
||||
v-if="meta.coverage.unrecoverable_bucket_count"
|
||||
class="text-amber-700 dark:text-amber-400"
|
||||
>{{ t('原始数据已清理,无法恢复的时间桶', 'Source data removed; unrecoverable buckets') }} {{ count(meta.coverage.unrecoverable_bucket_count) }}</span>
|
||||
<span
|
||||
v-if="stale"
|
||||
class="text-amber-700 dark:text-amber-400"
|
||||
>{{ t('数据水位滞后', 'Data delayed') }}</span>
|
||||
<span>{{ t('更新', 'Updated') }} {{ timestamp(meta.generated_at, meta.range.timezone) }}</span>
|
||||
<span v-if="meta.data_through">{{ t('数据截至', 'Data through') }} {{ timestamp(meta.data_through, meta.range.timezone) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="meta?.projection"
|
||||
class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<span v-if="!meta.projection.read_enabled">{{ t('原始事实读取', 'Reading source facts') }}</span>
|
||||
<span>{{ t('聚合覆盖至', 'Aggregate coverage through') }} {{ timestamp(meta.projection.projection_through, meta.range.timezone) }}</span>
|
||||
<span>{{ t('待重建', 'Awaiting rebuild') }} {{ count(meta.projection.dirty_bucket_count) }}</span>
|
||||
<span>{{ t('未建', 'Not built') }} {{ count(meta.projection.missing_bucket_count) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui'
|
||||
import type { OverviewMeta } from '@/api/overview'
|
||||
import { count, timestamp } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
const props = defineProps<{ meta?: OverviewMeta | null; loading?: boolean; error?: string | null }>()
|
||||
defineEmits<{ retry: [] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const partial = computed(() => props.meta?.coverage.status !== 'complete')
|
||||
const stale = computed(() => !!props.meta?.data_through && Date.parse(props.meta.data_through) < Math.min(Date.parse(props.meta.range.to), Date.now()) - 300_000)
|
||||
</script>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-lg font-semibold">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<slot name="subtitle" />
|
||||
</div>
|
||||
<div class="flex max-w-full flex-wrap items-center gap-2">
|
||||
<slot />
|
||||
<TimeRangePicker
|
||||
v-if="presetsOnly"
|
||||
:model-value="pickerRange"
|
||||
:preset-options="['last1hour', 'today', 'last24hours', 'last7days', 'last30days']"
|
||||
:show-granularity="false"
|
||||
preset-only
|
||||
@update:model-value="selectSharedPreset"
|
||||
/>
|
||||
<select
|
||||
v-else
|
||||
class="h-9 rounded-md border bg-background px-2 text-sm"
|
||||
:aria-label="t('时间范围', 'Time range')"
|
||||
:value="selectedPreset"
|
||||
@change="selectPreset"
|
||||
>
|
||||
<option value="custom">
|
||||
{{ t('自定义', 'Custom') }}
|
||||
</option>
|
||||
<option value="last1hour">
|
||||
{{ t('最近 1 小时', 'Last hour') }}
|
||||
</option>
|
||||
<option value="today">
|
||||
{{ t('今天', 'Today') }}
|
||||
</option>
|
||||
<option value="last24hours">
|
||||
{{ t('最近 24 小时', 'Last 24 hours') }}
|
||||
</option>
|
||||
<option value="last7days">
|
||||
{{ t('最近 7 天', 'Last 7 days') }}
|
||||
</option>
|
||||
<option value="last30days">
|
||||
{{ t('最近 30 天', 'Last 30 days') }}
|
||||
</option>
|
||||
</select>
|
||||
<Button
|
||||
v-if="!presetsOnly"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-9 w-9"
|
||||
:title="t('精确时间', 'Exact time')"
|
||||
:aria-label="t('精确时间', 'Exact time')"
|
||||
:aria-expanded="editing"
|
||||
@click="editing = !editing"
|
||||
>
|
||||
<CalendarRange class="h-4 w-4" />
|
||||
</Button>
|
||||
<RefreshButton
|
||||
:loading="loading"
|
||||
:active="refreshActive"
|
||||
:title="refreshTitle || t('刷新', 'Refresh')"
|
||||
:aria-label="refreshTitle || t('刷新', 'Refresh')"
|
||||
@click="$emit('refresh')"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<form
|
||||
v-if="editing && !presetsOnly"
|
||||
class="flex flex-wrap items-end gap-3 border-y py-3"
|
||||
@submit.prevent="apply"
|
||||
>
|
||||
<label class="grid min-w-0 gap-1 text-xs"><span>{{ t('开始', 'From') }}</span><input
|
||||
v-model="fromInput"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="h-9 max-w-full rounded-md border bg-background px-2 text-sm"
|
||||
></label>
|
||||
<label class="grid min-w-0 gap-1 text-xs"><span>{{ t('结束(不含)', 'To (exclusive)') }}</span><input
|
||||
v-model="toInput"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="h-9 max-w-full rounded-md border bg-background px-2 text-sm"
|
||||
></label>
|
||||
<label class="grid min-w-0 gap-1 text-xs"><span>{{ t('报表时区', 'Report timezone') }}</span><input
|
||||
v-model="zoneInput"
|
||||
list="overview-timezones"
|
||||
required
|
||||
class="h-9 w-44 max-w-full rounded-md border bg-background px-2 text-sm"
|
||||
><datalist id="overview-timezones"><option
|
||||
v-for="zone in zones"
|
||||
:key="zone"
|
||||
:value="zone"
|
||||
/></datalist></label>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
>
|
||||
{{ t('应用', 'Apply') }}
|
||||
</Button>
|
||||
<p
|
||||
v-if="rangeError"
|
||||
role="alert"
|
||||
class="w-full text-xs text-destructive"
|
||||
>
|
||||
{{ rangeError }}
|
||||
</p>
|
||||
</form>
|
||||
<p
|
||||
v-if="showRange"
|
||||
class="text-xs text-muted-foreground tabular-nums"
|
||||
>
|
||||
{{ timestamp(range.from, range.timezone) }} - {{ timestamp(range.to, range.timezone) }} · {{ range.timezone }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { CalendarRange } from 'lucide-vue-next'
|
||||
import { Button, RefreshButton } from '@/components/ui'
|
||||
import TimeRangePicker from '@/components/common/TimeRangePicker.vue'
|
||||
import type { DateRangeParams } from '@/features/usage/types'
|
||||
import type { OverviewRange } from '@/api/overview'
|
||||
import { browserTimezone, isTimezone, presetRange, zonedInput, zonedInstant } from '../query'
|
||||
import { timestamp } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
range: OverviewRange
|
||||
loading?: boolean
|
||||
preset?: string | null
|
||||
presetsOnly?: boolean
|
||||
showRange?: boolean
|
||||
refreshActive?: boolean
|
||||
refreshTitle?: string
|
||||
}>(), { preset: undefined, showRange: true, refreshActive: undefined, refreshTitle: undefined })
|
||||
const emit = defineEmits<{ 'update:range': [value: OverviewRange, preset?: string]; refresh: [] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const pickerRange = computed<DateRangeParams>(() => props.preset
|
||||
? { preset: props.preset, timezone: props.range.timezone, granularity: 'day' }
|
||||
: { ...props.range, granularity: 'day' })
|
||||
function selectSharedPreset(value: DateRangeParams) {
|
||||
if (value.preset) emit('update:range', presetRange(value.preset, props.range.timezone), value.preset)
|
||||
}
|
||||
const editing = ref(false)
|
||||
const selectedPreset = ref('custom')
|
||||
const fromInput = ref('')
|
||||
const toInput = ref('')
|
||||
const zoneInput = ref('UTC')
|
||||
const rangeError = ref('')
|
||||
const zones = [...new Set([browserTimezone(), 'UTC', 'Asia/Shanghai', 'America/New_York', 'Europe/Berlin'])]
|
||||
function syncRangeInputs(value: OverviewRange) {
|
||||
fromInput.value = zonedInput(value.from, value.timezone)
|
||||
toInput.value = zonedInput(value.to, value.timezone)
|
||||
zoneInput.value = value.timezone
|
||||
}
|
||||
watch(() => props.range, value => { if (!editing.value) syncRangeInputs(value) }, { immediate: true })
|
||||
watch(editing, open => { if (open) syncRangeInputs(props.range) })
|
||||
watch(() => props.preset, value => { if (value !== undefined) selectedPreset.value = value || 'custom' }, { immediate: true })
|
||||
function selectPreset(event: Event) {
|
||||
selectedPreset.value = (event.target as HTMLSelectElement).value
|
||||
if (selectedPreset.value === 'custom') { editing.value = true; return }
|
||||
editing.value = false
|
||||
emit('update:range', presetRange(selectedPreset.value, props.range.timezone), selectedPreset.value)
|
||||
}
|
||||
function apply() {
|
||||
const zone = zoneInput.value.trim()
|
||||
const from = zonedInstant(fromInput.value, zone)
|
||||
const to = zonedInstant(toInput.value, zone)
|
||||
if (!isTimezone(zone) || !from || !to || from >= to) {
|
||||
rangeError.value = t('请输入有效时区与起止时间;夏令时跳过的时间不可用。', 'Enter a valid timezone and time range. Times skipped by daylight saving are invalid.')
|
||||
return
|
||||
}
|
||||
rangeError.value = ''
|
||||
selectedPreset.value = 'custom'
|
||||
emit('update:range', { from, to, timezone: zone })
|
||||
editing.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<section class="min-w-0 space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ title || t('使用趋势', 'Usage trend') }}
|
||||
</h2><select
|
||||
v-model="metric"
|
||||
:aria-label="t('趋势指标', 'Trend metric')"
|
||||
class="h-8 rounded-md border bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="requests">
|
||||
{{ t('请求数', 'Requests') }}
|
||||
</option><option value="tokens">
|
||||
Tokens
|
||||
</option><option value="billable">
|
||||
{{ t('计费消耗', 'Billable consumption') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
v-if="!points.length"
|
||||
class="flex h-64 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('此时间范围暂无数据', 'No data in this range') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-64 min-w-0"
|
||||
>
|
||||
<LineChart :data="chart" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { OverviewSeriesPoint } from '@/api/overview'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
import { timestamp } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
const props = defineProps<{ points: OverviewSeriesPoint[]; timezone: string; title?: string }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const metric = ref('requests')
|
||||
const chart = computed(() => ({ labels: props.points.map(p => timestamp(p.bucket_start, props.timezone)), datasets: [{
|
||||
label: metric.value === 'tokens' ? 'Tokens' : metric.value === 'billable' ? t('计费消耗 (USD)', 'Billable consumption (USD)') : t('请求数', 'Requests'),
|
||||
data: props.points.map(p => metric.value === 'tokens' ? p.total_tokens : metric.value === 'billable' ? (p.billable_amount?.value == null ? null : Number(p.billable_amount.value)) : p.request_count),
|
||||
borderColor: '#0d9488', backgroundColor: '#0d9488', pointRadius: props.points.length > 60 ? 0 : 2, borderWidth: 2, tension: 0.15,
|
||||
}] }))
|
||||
</script>
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:open="open"
|
||||
:title="t('登记提供商支出', 'Record provider expense')"
|
||||
:description="t('记录已支付的充值、套餐或其他费用', 'Record a paid recharge, subscription or other expense')"
|
||||
size="lg"
|
||||
:persistent="saving"
|
||||
@update:open="value => !saving && emit('update:open', value)"
|
||||
>
|
||||
<form
|
||||
id="provider-expense-form"
|
||||
class="grid gap-4 py-2 sm:grid-cols-2"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<label class="grid gap-2 text-sm sm:col-span-2">
|
||||
{{ t('提供商', 'Provider') }}
|
||||
<select
|
||||
v-model="draft.provider_id"
|
||||
required
|
||||
class="h-10 min-w-0 rounded-lg border bg-background px-3"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
disabled
|
||||
>{{ t('选择提供商', 'Select a provider') }}</option>
|
||||
<option
|
||||
v-for="provider in providers"
|
||||
:key="provider.provider_id"
|
||||
:value="provider.provider_id"
|
||||
>{{ provider.provider_name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm">
|
||||
{{ t('支出类型', 'Expense type') }}
|
||||
<select
|
||||
v-model="draft.kind"
|
||||
class="h-10 rounded-lg border bg-background px-3"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option value="recharge">{{ t('余额充值', 'Balance recharge') }}</option>
|
||||
<option value="subscription">{{ t('套餐 / 续费', 'Subscription / renewal') }}</option>
|
||||
<option value="other">{{ t('其他支出', 'Other expense') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm">
|
||||
{{ t('币种', 'Currency') }}
|
||||
<input
|
||||
v-model="draft.currency"
|
||||
maxlength="3"
|
||||
pattern="[A-Za-z]{3}"
|
||||
required
|
||||
class="h-10 min-w-0 rounded-lg border bg-background px-3 uppercase"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm">
|
||||
{{ t('实付金额', 'Amount paid') }}
|
||||
<input
|
||||
v-model="draft.amount"
|
||||
inputmode="decimal"
|
||||
placeholder="0.00"
|
||||
required
|
||||
class="h-10 min-w-0 rounded-lg border bg-background px-3"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm">
|
||||
{{ t('付款时间', 'Paid at') }} · {{ timezone }}
|
||||
<input
|
||||
v-model="draft.paid_at"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="h-10 min-w-0 rounded-lg border bg-background px-3"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
<template v-if="draft.kind === 'subscription'">
|
||||
<label class="grid gap-2 text-sm">
|
||||
{{ t('套餐开始(选填)', 'Plan starts (optional)') }}
|
||||
<input
|
||||
v-model="draft.period_start"
|
||||
type="date"
|
||||
class="h-10 min-w-0 rounded-lg border bg-background px-3"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm">
|
||||
{{ t('套餐到期(选填)', 'Plan expires (optional)') }}
|
||||
<input
|
||||
v-model="draft.period_end"
|
||||
type="date"
|
||||
class="h-10 min-w-0 rounded-lg border bg-background px-3"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</template>
|
||||
<label class="grid gap-2 text-sm sm:col-span-2">
|
||||
{{ t('账单 / 交易号(选填)', 'Invoice / reference (optional)') }}
|
||||
<input
|
||||
v-model="draft.external_reference"
|
||||
maxlength="200"
|
||||
class="h-10 min-w-0 rounded-lg border bg-background px-3"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm sm:col-span-2">
|
||||
{{ t('备注(选填)', 'Note (optional)') }}
|
||||
<textarea
|
||||
v-model="draft.note"
|
||||
maxlength="1000"
|
||||
rows="2"
|
||||
class="min-w-0 resize-y rounded-lg border bg-background p-3"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</label>
|
||||
<p
|
||||
v-if="error"
|
||||
class="text-sm text-destructive sm:col-span-2"
|
||||
role="alert"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
</form>
|
||||
<template #footer>
|
||||
<Button
|
||||
type="submit"
|
||||
form="provider-expense-form"
|
||||
:disabled="saving || !providers.length"
|
||||
>
|
||||
{{ saving ? t('保存中…', 'Saving…') : t('保存记录', 'Save record') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="saving"
|
||||
@click="emit('update:open', false)"
|
||||
>
|
||||
{{ t('取消', 'Cancel') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { Button, Dialog } from '@/components/ui'
|
||||
import { providerFinanceApi, type ProviderAccount, type ProviderExpenseKind } from '@/api/providerFinance'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { zonedInput, zonedInstant } from '../query'
|
||||
|
||||
const props = defineProps<{ open: boolean; providers: ProviderAccount[]; timezone: string }>()
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean]; saved: [] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const draft = reactive({ provider_id: '', kind: 'recharge' as ProviderExpenseKind, amount: '', currency: 'USD', paid_at: '', period_start: '', period_end: '', note: '', external_reference: '' })
|
||||
let pending: { body: string; id: string } | null = null
|
||||
watch(() => props.open, open => {
|
||||
if (open && !draft.paid_at) draft.paid_at = zonedInput(new Date(), props.timezone)
|
||||
})
|
||||
async function submit() {
|
||||
if (saving.value) return
|
||||
error.value = ''
|
||||
const paidAt = zonedInstant(draft.paid_at, props.timezone)
|
||||
const start = draft.kind === 'subscription' && draft.period_start ? zonedInstant(`${draft.period_start}T00:00`, props.timezone) : null
|
||||
const end = draft.kind === 'subscription' && draft.period_end ? zonedInstant(`${draft.period_end}T00:00`, props.timezone) : null
|
||||
if (!draft.provider_id || !paidAt || !/^\d{1,12}(\.\d{1,8})?$/.test(draft.amount.trim()) || Number(draft.amount) <= 0 || !/^[A-Za-z]{3}$/.test(draft.currency)) {
|
||||
error.value = t('请填写提供商、有效付款时间、正数金额及三位币种代码。', 'Enter a provider, valid payment time, positive amount and three-letter currency code.')
|
||||
return
|
||||
}
|
||||
if (draft.kind === 'subscription' && ((Boolean(start) !== Boolean(end)) || (draft.period_start && !start) || (draft.period_end && !end) || (start && end && start >= end))) {
|
||||
error.value = t('请同时填写套餐开始和到期时间,且到期时间晚于开始时间。', 'Enter both plan dates, with expiry after the start.')
|
||||
return
|
||||
}
|
||||
const input = { provider_id: draft.provider_id, kind: draft.kind, amount: draft.amount.trim(), currency: draft.currency.toUpperCase(), paid_at: paidAt, period_start: start, period_end: end, note: draft.note.trim() || null, external_reference: draft.external_reference.trim() || null }
|
||||
const body = JSON.stringify(input)
|
||||
if (!pending || pending.body !== body) pending = { body, id: crypto.randomUUID() }
|
||||
saving.value = true
|
||||
try {
|
||||
await providerFinanceApi.record({ ...input, client_request_id: pending.id })
|
||||
pending = null
|
||||
Object.assign(draft, { provider_id: '', kind: 'recharge', amount: '', currency: 'USD', paid_at: '', period_start: '', period_end: '', note: '', external_reference: '' })
|
||||
emit('saved')
|
||||
emit('update:open', false)
|
||||
} catch (cause) {
|
||||
const failure = cause as { response?: { data?: { detail?: string; message?: string } }; message?: string }
|
||||
error.value = failure.response?.data?.detail || failure.response?.data?.message || failure.message || t('保存失败,请重试', 'Save failed; try again')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<section
|
||||
class="space-y-4"
|
||||
data-provider-expenses
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('充值与续费记录', 'Recharges and renewals') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ t('按付款时间统计已登记支出,作废记录不计入汇总', 'Recorded expenses by payment date; voided entries excluded') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="exporting || !data?.total"
|
||||
@click="exportReport"
|
||||
>
|
||||
<Download class="mr-1.5 h-3.5 w-3.5" />{{ t('导出支出报表', 'Export expenses') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="!canRecord"
|
||||
@click="emit('record')"
|
||||
>
|
||||
<Plus class="mr-1.5 h-3.5 w-3.5" />{{ t('登记支出', 'Record expense') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="actionError"
|
||||
class="text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{{ actionError }}
|
||||
</p>
|
||||
<Card class="min-w-0 overflow-hidden">
|
||||
<div class="overflow-x-auto px-4 sm:px-5">
|
||||
<table class="w-full min-w-[760px] text-sm">
|
||||
<thead class="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="py-3 text-left font-medium">
|
||||
{{ t('提供商 / 类型', 'Provider / type') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('实付金额', 'Amount paid') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('付款时间', 'Payment date') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('套餐周期', 'Plan period') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('账单与备注', 'Reference and note') }}
|
||||
</th>
|
||||
<th class="py-3 text-right font-medium">
|
||||
{{ t('操作', 'Action') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
<tr
|
||||
v-for="item in data?.items || []"
|
||||
:key="item.id"
|
||||
>
|
||||
<td class="max-w-44 break-words py-3 font-medium">
|
||||
{{ item.provider_name }}<p class="mt-1 text-xs font-normal text-muted-foreground">
|
||||
{{ kindLabel(item.kind) }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-right tabular-nums">
|
||||
{{ amount(item) }}<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ item.currency }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-xs">
|
||||
{{ timestamp(item.paid_at, range.timezone) }}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-xs text-muted-foreground">
|
||||
{{ item.period_start ? timestamp(item.period_start, range.timezone) : '-' }}<br>{{ item.period_end ? timestamp(item.period_end, range.timezone) : '-' }}
|
||||
</td>
|
||||
<td class="max-w-56 break-words px-3 py-3 text-xs">
|
||||
{{ item.external_reference || '-' }}<p
|
||||
v-if="item.note"
|
||||
class="mt-1 text-muted-foreground"
|
||||
>
|
||||
{{ item.note }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="py-3 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="`${t('作废', 'Void')} ${item.provider_name} ${amount(item)}`"
|
||||
@click="voiding = item"
|
||||
>
|
||||
{{ t('作废', 'Void') }}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!data?.items.length">
|
||||
<td
|
||||
colspan="6"
|
||||
class="py-12 text-center text-muted-foreground"
|
||||
>
|
||||
{{ loading ? t('加载支出记录…', 'Loading expenses…') : data ? t('此时段尚未登记支出', 'No recorded expenses in this range') : t('暂未取得支出记录', 'Expense records unavailable') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-if="data"
|
||||
class="px-4 pb-3 sm:px-5"
|
||||
>
|
||||
<OverviewPagination
|
||||
:total="data.total"
|
||||
:offset="data.offset"
|
||||
:limit="data.limit"
|
||||
:loading="loading"
|
||||
@page="value => emit('page', value)"
|
||||
@size="value => emit('size', value)"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Dialog
|
||||
:open="Boolean(voiding)"
|
||||
:title="t('作废支出记录', 'Void expense record')"
|
||||
:persistent="saving"
|
||||
@update:open="value => !value && !saving && (voiding = null)"
|
||||
>
|
||||
<p class="py-3 text-sm">
|
||||
{{ t('确认作废这笔记录?记录会保留用于审计,并从支出汇总中扣除。', 'Void this record? It will remain in the audit history and be excluded from expense totals.') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="voiding"
|
||||
class="pb-3 font-medium"
|
||||
>
|
||||
{{ voiding.provider_name }} · {{ amount(voiding) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="voidError"
|
||||
class="pb-3 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{{ voidError }}
|
||||
</p>
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="destructive"
|
||||
:disabled="saving"
|
||||
@click="confirmVoid"
|
||||
>
|
||||
{{ saving ? t('处理中…', 'Processing…') : t('确认作废', 'Void record') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="saving"
|
||||
@click="voiding = null"
|
||||
>
|
||||
{{ t('取消', 'Cancel') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onScopeDispose, ref, watch } from 'vue'
|
||||
import { Download, Plus } from 'lucide-vue-next'
|
||||
import { Button, Card, Dialog } from '@/components/ui'
|
||||
import { providerFinanceApi, type ProviderExpense, type ProviderExpenseKind, type ProviderExpenses } from '@/api/providerFinance'
|
||||
import type { OverviewRange } from '@/api/overview'
|
||||
import OverviewPagination from '../components/OverviewPagination.vue'
|
||||
import { money, timestamp } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
const props = defineProps<{ data: ProviderExpenses | null; range: OverviewRange; offset: number; loading: boolean; canRecord: boolean }>()
|
||||
const emit = defineEmits<{ record: []; page: [offset: number]; size: [limit: number]; changed: [] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const exporting = ref(false)
|
||||
const actionError = ref('')
|
||||
const voidError = ref('')
|
||||
const voiding = ref<ProviderExpense | null>(null)
|
||||
const saving = ref(false)
|
||||
let controller: AbortController | null = null
|
||||
watch(voiding, () => { voidError.value = '' })
|
||||
watch(() => JSON.stringify(props.range), () => { actionError.value = '' })
|
||||
function amount(item: ProviderExpense) { return money({ value: item.amount, currency: item.currency, basis: 'provider_expense', status: 'known' }) }
|
||||
function kindLabel(kind: ProviderExpenseKind) { return kind === 'subscription' ? t('套餐 / 续费', 'Subscription / renewal') : kind === 'recharge' ? t('余额充值', 'Balance recharge') : t('其他支出', 'Other expense') }
|
||||
function message(cause: unknown) {
|
||||
const error = cause as { response?: { data?: { detail?: string; message?: string } }; message?: string }
|
||||
return error.response?.data?.detail || error.response?.data?.message || error.message || t('操作失败,请重试', 'Action failed; try again')
|
||||
}
|
||||
async function confirmVoid() {
|
||||
if (!voiding.value || saving.value) return
|
||||
saving.value = true
|
||||
try { await providerFinanceApi.void(voiding.value.id); voiding.value = null; emit('changed') }
|
||||
catch (cause) { voidError.value = message(cause) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function exportReport() {
|
||||
if (exporting.value) return
|
||||
actionError.value = ''
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await providerFinanceApi.exportExpenses(props.range, controller.signal)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `aether-provider-expenses-${props.range.from.slice(0, 10)}.csv`
|
||||
anchor.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted) return
|
||||
const body = (cause as { response?: { data?: Blob } }).response?.data
|
||||
if (body instanceof Blob) {
|
||||
try { actionError.value = JSON.parse(await body.text()).detail || message(cause) }
|
||||
catch { actionError.value = message(cause) }
|
||||
} else actionError.value = message(cause)
|
||||
} finally { exporting.value = false }
|
||||
}
|
||||
onScopeDispose(() => controller?.abort())
|
||||
</script>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<section
|
||||
class="space-y-4"
|
||||
data-provider-accounts
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('账户余额与套餐', 'Balances and subscriptions') }}
|
||||
</h2>
|
||||
<span class="text-xs text-muted-foreground">{{ t('当前账户状态', 'Current account status') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="accounts.length"
|
||||
class="grid grid-cols-1 gap-4 xl:grid-cols-2"
|
||||
>
|
||||
<Card
|
||||
v-for="account in orderedAccounts"
|
||||
:key="account.provider_id"
|
||||
class="min-w-0 space-y-4 p-4 sm:p-5"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="break-words text-sm font-semibold">
|
||||
{{ account.provider_name }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ billingLabel(account.billing_type) }}<span v-if="!account.is_active"> · {{ t('已停用', 'Disabled') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
v-if="attention(account)"
|
||||
class="shrink-0 rounded-full bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-400"
|
||||
>{{ attention(account) }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('上游余额', 'Upstream balance') }}
|
||||
</p>
|
||||
<p class="mt-2 text-xl font-semibold tabular-nums">
|
||||
{{ amount(account.balance?.available, account.balance?.currency) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ account.balance?.observed_at ? `${t('更新于', 'Updated')} ${timestamp(account.balance.observed_at, timezone)}` : t('暂无余额快照', 'No balance snapshot') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="account.balance && account.balance.status !== 'success'"
|
||||
class="mt-1 text-xs text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
{{ t('上次查询未成功', 'Last query unsuccessful') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('配置套餐剩余额度', 'Configured plan remaining') }}
|
||||
</p>
|
||||
<p class="mt-2 text-xl font-semibold tabular-nums">
|
||||
{{ amount(account.quota?.remaining, account.quota?.currency) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ account.quota ? `${t('周期总额度', 'Period quota')} ${amount(account.quota.limit, account.quota.currency)}` : t('未配置套餐额度', 'No configured plan quota') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="account.quota"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div class="flex flex-wrap justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ t('已使用', 'Used') }} {{ amount(account.quota.used, account.quota.currency) }}</span>
|
||||
<span>{{ account.quota.expires_at ? `${t('到期', 'Expires')} ${timestamp(account.quota.expires_at, timezone)}` : t('未设置到期时间', 'No expiry configured') }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
:class="quotaRatio(account) >= 90 ? 'bg-amber-500' : 'bg-primary/65'"
|
||||
:style="{ width: `${quotaRatio(account)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="account.balance?.plan_name || account.balance?.subscriptions.length"
|
||||
class="space-y-3 border-t pt-3"
|
||||
>
|
||||
<p
|
||||
v-if="account.balance.plan_name"
|
||||
class="text-xs font-medium"
|
||||
>
|
||||
{{ t('上游套餐', 'Upstream plan') }} · {{ account.balance.plan_name }}
|
||||
</p>
|
||||
<div
|
||||
v-for="(plan, index) in account.balance.subscriptions"
|
||||
:key="index"
|
||||
class="space-y-1 text-xs"
|
||||
>
|
||||
<div class="flex flex-wrap justify-between gap-2">
|
||||
<span class="font-medium">{{ plan.group_name || t('订阅套餐', 'Subscription') }}</span>
|
||||
<span class="text-muted-foreground">{{ plan.expires_at ? timestamp(plan.expires_at, timezone) : t('未提供到期时间', 'No expiry reported') }}</span>
|
||||
</div>
|
||||
<p class="text-muted-foreground">
|
||||
{{ planUsage(plan) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card
|
||||
v-else
|
||||
class="p-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('暂无提供商账户', 'No provider accounts') }}
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Card } from '@/components/ui'
|
||||
import type { ProviderAccount, ProviderAccountSubscription } from '@/api/providerFinance'
|
||||
import { money, timestamp } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
const props = defineProps<{ accounts: ProviderAccount[]; timezone: string; observedAt?: string }>()
|
||||
const { t } = useOverviewI18n()
|
||||
function amount(value: string | number | null | undefined, currency: string | null = 'USD') { return value == null || !currency || !/^[a-z]{3}$/i.test(currency) ? '-' : money({ value: String(value), currency, basis: 'provider_account', status: 'known' }) }
|
||||
function billingLabel(value: string | null) {
|
||||
return value === 'monthly_quota' ? t('周期套餐', 'Period plan') : value === 'free_tier' ? t('免费额度', 'Free tier') : value === 'pay_as_you_go' ? t('按量付费', 'Pay as you go') : t('未设置计费方式', 'Billing type not configured')
|
||||
}
|
||||
function quotaRatio(account: ProviderAccount) {
|
||||
return account.quota && Number(account.quota.limit) > 0 ? Math.min(100, Math.max(0, Number(account.quota.used) / Number(account.quota.limit) * 100)) : 0
|
||||
}
|
||||
function attention(account: ProviderAccount) {
|
||||
if (!account.is_active) return ''
|
||||
const now = props.observedAt ? Date.parse(props.observedAt) : Date.now()
|
||||
const end = account.quota?.expires_at ? Date.parse(account.quota.expires_at) : NaN
|
||||
if (Number.isFinite(end) && end <= now) return t('套餐已到期', 'Plan expired')
|
||||
if (Number.isFinite(end) && end - now <= 7 * 86_400_000) return t('7 天内到期', 'Expires within 7 days')
|
||||
if (account.quota?.remaining != null && Number(account.quota.remaining) <= 0) return t('套餐额度用尽', 'Plan quota exhausted')
|
||||
if (quotaRatio(account) >= 90) return t('套餐剩余不足 10%', 'Under 10% quota remaining')
|
||||
return ''
|
||||
}
|
||||
const orderedAccounts = computed(() => [...props.accounts].sort((a, b) => Number(Boolean(attention(b))) - Number(Boolean(attention(a))) || a.provider_name.localeCompare(b.provider_name)))
|
||||
function planUsage(plan: ProviderAccountSubscription) {
|
||||
return [
|
||||
[t('日额度', 'Daily'), plan.daily_used_usd, plan.daily_limit_usd],
|
||||
[t('周额度', 'Weekly'), plan.weekly_used_usd, plan.weekly_limit_usd],
|
||||
[t('月额度', 'Monthly'), plan.monthly_used_usd, plan.monthly_limit_usd],
|
||||
].filter(([, , limit]) => limit != null).map(([label, used, limit]) => `${label} ${amount(used)} / ${amount(limit)}`).join(' · ') || t('暂无额度明细', 'Quota details unavailable')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<section class="space-y-3 border-t pt-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('提供商套餐额度', 'Provider plan quota') }}
|
||||
</h2>
|
||||
<span class="text-xs text-muted-foreground">{{ t('当前配置 · 全站', 'Current configuration · Site-wide') }}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="loading"
|
||||
:title="t('刷新额度', 'Refresh quota')"
|
||||
:aria-label="t('刷新额度', 'Refresh quota')"
|
||||
@click="refresh"
|
||||
>
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<OverviewStatus
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
@retry="refresh"
|
||||
/>
|
||||
<div
|
||||
v-if="data?.providers.length"
|
||||
class="overflow-x-auto"
|
||||
>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-y text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="py-3 text-left">
|
||||
{{ t('提供商', 'Provider') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right">
|
||||
{{ t('已使用 / 额度', 'Used / Quota') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right">
|
||||
{{ t('剩余', 'Remaining') }}
|
||||
</th>
|
||||
<th class="min-w-32 px-3 py-3 text-left">
|
||||
{{ t('使用进度', 'Utilization') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left">
|
||||
{{ t('到期时间', 'Expires') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left">
|
||||
{{ t('预计耗尽', 'Estimated depletion') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="provider in data.providers"
|
||||
:key="provider.id"
|
||||
class="border-b"
|
||||
>
|
||||
<td class="max-w-56 break-words py-3 font-medium">
|
||||
{{ provider.name }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-right tabular-nums">
|
||||
{{ usd(provider.used_usd) }} / {{ usd(provider.quota_usd) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-right tabular-nums">
|
||||
{{ usd(provider.remaining_usd) }}
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<span class="text-xs tabular-nums">{{ percent(provider.usage_percent / 100) }}</span><div class="mt-1 h-1.5 w-24 overflow-hidden rounded bg-muted">
|
||||
<div
|
||||
class="h-full"
|
||||
:class="provider.usage_percent >= 90 ? 'bg-amber-500' : 'bg-emerald-600'"
|
||||
:style="{ width: `${Math.min(100, Math.max(0, provider.usage_percent))}%` }"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-xs">
|
||||
{{ timestamp(provider.quota_expires_at, timezone) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-xs">
|
||||
{{ timestamp(provider.estimated_exhaust_at, timezone) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p
|
||||
v-else-if="data"
|
||||
class="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('暂无已配置的提供商额度', 'No provider quota configured') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="data"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('读取时间', 'Retrieved') }} {{ timestamp(data.retrievedAt, timezone) }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { Button } from '@/components/ui'
|
||||
import OverviewStatus from '../components/OverviewStatus.vue'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { money, percent, timestamp } from '../format'
|
||||
defineProps<{ timezone: string }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { data, loading, error, refresh } = useOverviewRequest(() => 'provider-quota', async () => ({ ...await adminApi.getQuotaUsage(), retrievedAt: new Date().toISOString() }))
|
||||
function usd(value: number) { return money({ value: String(value), currency: 'USD', basis: 'provider_plan', status: 'known' }) }
|
||||
</script>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('统计周期', 'Statistics period') }}
|
||||
</h2>
|
||||
<div class="flex max-w-full flex-wrap items-center gap-2">
|
||||
<select
|
||||
:value="preset"
|
||||
:aria-label="t('图表时间范围', 'Chart time range')"
|
||||
class="h-8 rounded-md border bg-background px-2 text-xs"
|
||||
@change="selectPreset"
|
||||
>
|
||||
<option value="last7days">
|
||||
{{ t('最近 7 天', 'Last 7 days') }}
|
||||
</option>
|
||||
<option value="last30days">
|
||||
{{ t('最近 30 天', 'Last 30 days') }}
|
||||
</option>
|
||||
<option value="today">
|
||||
{{ t('今天', 'Today') }}
|
||||
</option>
|
||||
<option value="custom">
|
||||
{{ t('自定义', 'Custom') }}
|
||||
</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="t('精确时间', 'Exact time')"
|
||||
:aria-label="t('精确时间', 'Exact time')"
|
||||
:aria-expanded="editing"
|
||||
@click="editing = !editing"
|
||||
>
|
||||
<CalendarRange class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="loading"
|
||||
:title="t('刷新图表', 'Refresh charts')"
|
||||
:aria-label="t('刷新图表', 'Refresh charts')"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
<RefreshCw
|
||||
class="h-4 w-4"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
v-if="editing"
|
||||
class="flex max-w-full flex-wrap items-end gap-3 border-y py-3"
|
||||
@submit.prevent="apply"
|
||||
>
|
||||
<label class="grid w-48 max-w-full gap-1 text-xs"><span>{{ t('开始', 'From') }}</span><input
|
||||
v-model="fromInput"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="h-9 w-full min-w-0 rounded-md border bg-background px-2 text-sm"
|
||||
></label>
|
||||
<label class="grid w-48 max-w-full gap-1 text-xs"><span>{{ t('结束(不含)', 'To (exclusive)') }}</span><input
|
||||
v-model="toInput"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="h-9 w-full min-w-0 rounded-md border bg-background px-2 text-sm"
|
||||
></label>
|
||||
<label class="grid w-44 max-w-full gap-1 text-xs"><span>{{ t('报表时区', 'Report timezone') }}</span><input
|
||||
v-model="zoneInput"
|
||||
list="dashboard-timezones"
|
||||
required
|
||||
class="h-9 w-full min-w-0 rounded-md border bg-background px-2 text-sm"
|
||||
><datalist id="dashboard-timezones"><option
|
||||
v-for="zone in zones"
|
||||
:key="zone"
|
||||
:value="zone"
|
||||
/></datalist></label>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
>
|
||||
{{ t('应用', 'Apply') }}
|
||||
</Button>
|
||||
<p
|
||||
v-if="rangeError"
|
||||
role="alert"
|
||||
class="w-full text-xs text-destructive"
|
||||
>
|
||||
{{ rangeError }}
|
||||
</p>
|
||||
</form>
|
||||
<p class="text-xs text-muted-foreground tabular-nums">
|
||||
{{ timestamp(range.from, range.timezone) }} - {{ timestamp(range.to, range.timezone) }} · {{ range.timezone }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { CalendarRange, RefreshCw } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui'
|
||||
import type { OverviewRange } from '@/api/overview'
|
||||
import { browserTimezone, isTimezone, presetRange, zonedInput, zonedInstant } from '../query'
|
||||
import { timestamp } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
|
||||
const props = defineProps<{ range: OverviewRange; loading?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:range': [value: OverviewRange]; refresh: [] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const editing = ref(false)
|
||||
const preset = ref('custom')
|
||||
const fromInput = ref('')
|
||||
const toInput = ref('')
|
||||
const zoneInput = ref('UTC')
|
||||
const rangeError = ref('')
|
||||
const zones = [...new Set([browserTimezone(), 'UTC', 'Asia/Shanghai', 'America/New_York', 'Europe/Berlin'])]
|
||||
watch(() => props.range, value => {
|
||||
fromInput.value = zonedInput(value.from, value.timezone)
|
||||
toInput.value = zonedInput(value.to, value.timezone)
|
||||
zoneInput.value = value.timezone
|
||||
const duration = Date.parse(value.to) - Date.parse(value.from)
|
||||
const recent = Math.abs(Date.now() - Date.parse(value.to)) < 120_000
|
||||
preset.value = recent && duration === 7 * 86_400_000 ? 'last7days' : recent && duration === 30 * 86_400_000 ? 'last30days'
|
||||
: recent && value.from === presetRange('today', value.timezone, new Date(value.to)).from ? 'today' : 'custom'
|
||||
}, { immediate: true })
|
||||
function selectPreset(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
preset.value = value
|
||||
if (value === 'custom') { editing.value = true; return }
|
||||
editing.value = false
|
||||
emit('update:range', presetRange(value, props.range.timezone))
|
||||
}
|
||||
function apply() {
|
||||
const zone = zoneInput.value.trim()
|
||||
const from = zonedInstant(fromInput.value, zone)
|
||||
const to = zonedInstant(toInput.value, zone)
|
||||
if (!isTimezone(zone) || !from || !to || from >= to) {
|
||||
rangeError.value = t('请输入有效时区与起止时间;夏令时跳过的时间不可用。', 'Enter a valid timezone and time range. Times skipped by daylight saving are invalid.')
|
||||
return
|
||||
}
|
||||
rangeError.value = ''
|
||||
emit('update:range', { from, to, timezone: zone })
|
||||
editing.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="grid min-w-0 grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<section class="min-w-0 space-y-3">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('每日模型费用', 'Daily model cost') }}
|
||||
</h2>
|
||||
<div class="h-[280px] min-w-0">
|
||||
<BarChart
|
||||
v-if="modelChart.datasets.length && hasModelAmount"
|
||||
:data="modelChart"
|
||||
:options="modelOptions"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground"
|
||||
>
|
||||
{{ emptyCostLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="min-w-0 space-y-3">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('提供商费用分布', 'Provider cost distribution') }}
|
||||
</h2>
|
||||
<div class="h-[280px] min-w-0">
|
||||
<DoughnutChart
|
||||
v-if="slices.length"
|
||||
:data="providerChart"
|
||||
:options="providerOptions"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground"
|
||||
>
|
||||
{{ emptyCostLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<p
|
||||
v-if="partial"
|
||||
class="text-xs text-amber-700 lg:col-span-2 dark:text-amber-400"
|
||||
>
|
||||
{{ t('费用为已知小计,分布占比按已知金额计算', 'Costs are known subtotals; distribution shares use known amounts') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import type { OverviewDashboardCharts } from '@/api/overview'
|
||||
import BarChart from '@/components/charts/BarChart.vue'
|
||||
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
|
||||
import { money } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { amountValue } from './amount'
|
||||
import { chartColors, chartDate, modelDatasets, providerSlices } from './charts'
|
||||
|
||||
const props = defineProps<{ data: OverviewDashboardCharts; timezone: string }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const currency = computed(() => props.data.summary.billable_amount.currency)
|
||||
const formatCost = (value: number) => money({ value: String(value), currency: currency.value, basis: 'billable', status: 'known' })
|
||||
const partial = computed(() => {
|
||||
const rows = [props.data.summary, ...props.data.models, ...props.data.providers]
|
||||
return rows.some(row => amountValue(row.billable_amount) !== null)
|
||||
&& rows.some(row => row.billable_amount.status !== 'known')
|
||||
})
|
||||
const emptyCostLabel = computed(() => props.data.summary.request_count === 0 ? t('暂无使用数据', 'No usage data')
|
||||
: amountValue(props.data.summary.billable_amount) === null ? t('费用尚未确认', 'Cost not yet known') : t('此周期暂无计费费用', 'No billable cost in this period'))
|
||||
const modelChart = computed<ChartData<'bar'>>(() => ({
|
||||
labels: props.data.series.map(day => chartDate(day.bucket_start, props.timezone)),
|
||||
datasets: modelDatasets(props.data, t('未知模型', 'Unknown model'), t('其他模型', 'Other models')),
|
||||
}))
|
||||
const hasModelAmount = computed(() => modelChart.value.datasets.some(dataset => dataset.data.some(value => typeof value === 'number' && value !== 0)))
|
||||
const modelOptions = computed<ChartOptions<'bar'>>(() => ({
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 0 } },
|
||||
y: { stacked: true, ticks: { font: { size: 10 } }, title: { display: true, text: `${t('费用', 'Cost')} (${currency.value})`, font: { size: 10 } } },
|
||||
},
|
||||
plugins: {
|
||||
legend: { position: 'bottom', labels: { font: { size: 10 }, boxWidth: 10, padding: 10,
|
||||
generateLabels: chart => chart.data.datasets.map((dataset, index) => ({ text: shorten(dataset.label || ''), fillStyle: chartColors[index % chartColors.length], hidden: !chart.isDatasetVisible(index), datasetIndex: index })),
|
||||
} },
|
||||
tooltip: { callbacks: {
|
||||
label: context => `${context.dataset.label}: ${typeof context.raw === 'number' ? formatCost(context.raw) : '-'}`,
|
||||
footer: items => `${t('已显示合计', 'Visible subtotal')}: ${formatCost(items.reduce((sum, item) => sum + (typeof item.raw === 'number' ? item.raw : 0), 0))}`,
|
||||
} },
|
||||
},
|
||||
}))
|
||||
const slices = computed(() => providerSlices(props.data.providers, t('未知提供商', 'Unknown provider'), t('其他提供商', 'Other providers')))
|
||||
const providerChart = computed<ChartData<'doughnut'>>(() => ({
|
||||
labels: slices.value.map(slice => slice.label),
|
||||
datasets: [{ data: slices.value.map(slice => slice.value), backgroundColor: slices.value.map((_, index) => chartColors[index % chartColors.length]), borderWidth: 2, borderColor: 'rgba(255,255,255,0.15)' }],
|
||||
}))
|
||||
const providerOptions = computed<ChartOptions<'doughnut'>>(() => ({
|
||||
responsive: true, maintainAspectRatio: false, cutout: '60%',
|
||||
plugins: {
|
||||
legend: { position: 'bottom', labels: { font: { size: 10 }, boxWidth: 10, padding: 10,
|
||||
generateLabels: chart => slices.value.map((slice, index) => ({ text: shorten(slice.label), fillStyle: chartColors[index % chartColors.length], hidden: !chart.getDataVisibility(index), index })),
|
||||
} },
|
||||
tooltip: { callbacks: { label: context => {
|
||||
const value = typeof context.raw === 'number' ? context.raw : 0
|
||||
const total = slices.value.reduce((sum, slice) => sum + slice.value, 0)
|
||||
return `${context.label}: ${formatCost(value)} (${total > 0 ? (value / total * 100).toFixed(1) : '0'}%)`
|
||||
} } },
|
||||
},
|
||||
}))
|
||||
function shorten(value: string) { return value.length > 25 ? `${value.slice(0, 24)}...` : value }
|
||||
</script>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<section class="min-w-0 space-y-3 border-t pt-5">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('每日统计', 'Daily statistics') }}
|
||||
</h2>
|
||||
<div class="divide-y sm:hidden">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.bucket_start"
|
||||
class="space-y-2 py-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<RouterLink
|
||||
:to="dailyLink(row.bucket_start)"
|
||||
class="font-medium hover:text-primary hover:underline"
|
||||
>
|
||||
{{ chartDate(row.bucket_start, range.timezone) }}
|
||||
</RouterLink>
|
||||
<span class="tabular-nums">{{ money(row.billable_amount) }}<span
|
||||
v-if="amountStatus(row.billable_amount, t)"
|
||||
class="ml-1 text-amber-700 dark:text-amber-400"
|
||||
>{{ amountStatus(row.billable_amount, t) }}</span></span>
|
||||
</div>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
|
||||
<div class="flex flex-wrap justify-between gap-x-2">
|
||||
<dt class="text-muted-foreground">
|
||||
{{ t('请求', 'Requests') }}
|
||||
</dt><dd class="tabular-nums">
|
||||
{{ count(row.request_count) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-between gap-x-2">
|
||||
<dt class="text-muted-foreground">
|
||||
Tokens
|
||||
</dt><dd class="tabular-nums">
|
||||
{{ count(row.total_tokens) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-between gap-x-2">
|
||||
<dt class="text-muted-foreground">
|
||||
{{ t('响应', 'Response') }}
|
||||
</dt><dd class="tabular-nums">
|
||||
{{ latency(row.latency_ms.avg) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-between gap-x-2">
|
||||
<dt class="text-muted-foreground">
|
||||
{{ t('成功率', 'Success rate') }}
|
||||
</dt><dd class="tabular-nums">
|
||||
{{ percent(row.success_rate.value) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="space-y-2 py-3 text-xs">
|
||||
<div class="flex flex-wrap justify-between gap-2 font-medium">
|
||||
<span>{{ t('周期合计', 'Period total') }}</span><span>{{ money(data.summary.billable_amount) }}<span
|
||||
v-if="amountStatus(data.summary.billable_amount, t)"
|
||||
class="ml-1 text-amber-700 dark:text-amber-400"
|
||||
>{{ amountStatus(data.summary.billable_amount, t) }}</span></span>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-between gap-2 text-muted-foreground">
|
||||
<span>{{ t('请求', 'Requests') }} {{ count(data.summary.request_count) }}</span><span>Tokens {{ count(data.summary.total_tokens) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden overflow-x-auto sm:block">
|
||||
<table class="w-full min-w-[600px] text-left text-xs tabular-nums">
|
||||
<thead class="border-b text-muted-foreground">
|
||||
<tr>
|
||||
<th class="py-3 font-medium">
|
||||
{{ t('日期', 'Date') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('请求次数', 'Requests') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right font-medium">
|
||||
Tokens
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('费用', 'Cost') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('平均响应', 'Avg response') }}
|
||||
</th>
|
||||
<th class="py-3 pl-3 text-right font-medium">
|
||||
{{ t('成功率', 'Success rate') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
<tr v-if="!rows.length">
|
||||
<td
|
||||
colspan="6"
|
||||
class="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{{ t('暂无数据', 'No data') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="row in rows"
|
||||
:key="row.bucket_start"
|
||||
>
|
||||
<td class="py-3">
|
||||
<RouterLink
|
||||
:to="dailyLink(row.bucket_start)"
|
||||
class="font-medium hover:text-primary hover:underline"
|
||||
>
|
||||
{{ chartDate(row.bucket_start, range.timezone) }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
{{ count(row.request_count) }}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
{{ count(row.total_tokens) }}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
{{ money(row.billable_amount) }}
|
||||
<span
|
||||
v-if="amountStatus(row.billable_amount, t)"
|
||||
class="block text-[10px] text-amber-700 dark:text-amber-400"
|
||||
>{{ amountStatus(row.billable_amount, t) }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
{{ latency(row.latency_ms.avg) }}
|
||||
</td>
|
||||
<td class="py-3 pl-3 text-right">
|
||||
{{ percent(row.success_rate.value) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot class="border-t bg-muted/30 font-medium">
|
||||
<tr>
|
||||
<td class="py-3">
|
||||
{{ t('周期合计', 'Period total') }}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
{{ count(data.summary.request_count) }}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
{{ count(data.summary.total_tokens) }}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
{{ money(data.summary.billable_amount) }}
|
||||
<span
|
||||
v-if="amountStatus(data.summary.billable_amount, t)"
|
||||
class="block text-[10px] text-amber-700 dark:text-amber-400"
|
||||
>{{ amountStatus(data.summary.billable_amount, t) }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
{{ latency(data.summary.latency_ms.avg) }}
|
||||
</td>
|
||||
<td class="py-3 pl-3 text-right">
|
||||
{{ percent(data.summary.success_rate.value) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { OverviewDashboardCharts, OverviewRange } from '@/api/overview'
|
||||
import { count, money, percent } from '../format'
|
||||
import { rangeQuery } from '../query'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { amountStatus } from './amount'
|
||||
import { chartDate, dayRange } from './charts'
|
||||
|
||||
const props = defineProps<{ data: OverviewDashboardCharts; range: OverviewRange }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const rows = computed(() => [...props.data.series].sort((a, b) => b.bucket_start.localeCompare(a.bucket_start)))
|
||||
const latency = (value: number | null) => value == null ? '-' : `${count(value)} ms`
|
||||
function dailyLink(bucket: string) { return { path: '/admin/usage', query: rangeQuery(dayRange(bucket, props.range)) } }
|
||||
</script>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-stretch gap-4 xl:grid-cols-2">
|
||||
<Card
|
||||
class="min-w-0 overflow-hidden p-4 sm:p-5"
|
||||
:title="scopeHint"
|
||||
:aria-busy="loading"
|
||||
data-dashboard-activity
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3 sm:items-center">
|
||||
<div class="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h3 class="text-sm font-medium text-foreground">
|
||||
{{ t('连续活跃', 'Activity streak') }}
|
||||
</h3>
|
||||
<Skeleton
|
||||
v-if="loading"
|
||||
class="h-8 w-28"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="flex shrink-0 items-baseline gap-1.5"
|
||||
:title="t('连续活跃天数 / 总活跃天数。今天尚无请求时,从昨天向前计算;有请求的日期计为一个活跃日。', 'Consecutive active days / total active days. If today has no requests yet, the streak ends yesterday. Each date with requests counts as one active day.')"
|
||||
>
|
||||
<span class="text-[28px] font-semibold leading-8 tabular-nums text-foreground">{{ count(consecutiveActiveDays) }} / {{ count(activeDays) }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ t('天', 'days') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1 pt-1 text-[10px] text-muted-foreground sm:pt-0">
|
||||
<span class="mr-0.5">{{ t('少', 'Less') }}</span>
|
||||
<span
|
||||
v-for="level in legendLevels"
|
||||
:key="level"
|
||||
class="h-3 w-3 rounded-[4px]"
|
||||
:style="{ backgroundColor: `rgba(var(--color-primary-rgb), ${level})` }"
|
||||
/>
|
||||
<span class="ml-0.5">{{ t('多', 'More') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton
|
||||
v-if="loading"
|
||||
class="mt-4 h-24 w-full"
|
||||
/>
|
||||
<ActivityHeatmap
|
||||
v-else-if="data?.days.length"
|
||||
class="mt-4"
|
||||
:data="data"
|
||||
:show-header="false"
|
||||
:compact="true"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="mt-4 flex h-24 items-center justify-center text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('暂无活跃数据', 'No activity yet') }}
|
||||
</div>
|
||||
</Card>
|
||||
<IntervalTimelineCard
|
||||
:title="t('全站请求间隔(最近24小时)', 'Site-wide request intervals (last 24 hours)')"
|
||||
:is-admin="true"
|
||||
:hours="24"
|
||||
:refresh-interval-ms="0"
|
||||
:data="timelineData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Card, Skeleton } from '@/components/ui'
|
||||
import ActivityHeatmap from '@/components/stats/ActivityHeatmap.vue'
|
||||
import IntervalTimelineCard from '@/features/usage/components/IntervalTimelineCard.vue'
|
||||
import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
|
||||
import type { IntervalTimelineResponse } from '@/api/cache'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { count } from '../format'
|
||||
|
||||
defineProps<{ data: ActivityHeatmapData | null; consecutiveActiveDays: number | null; activeDays: number | null; scopeHint: string; loading: boolean; error: boolean; timelineData?: IntervalTimelineResponse | null }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||
</script>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<aside
|
||||
id="announcements-section"
|
||||
class="flex min-h-0 min-w-0 flex-col"
|
||||
:aria-label="text('系统公告', 'System announcements')"
|
||||
>
|
||||
<div class="mb-4 flex h-5 shrink-0 items-center justify-between gap-2">
|
||||
<h3 class="text-sm font-medium text-foreground">
|
||||
{{ text('系统公告', 'System announcements') }}
|
||||
</h3>
|
||||
<RouterLink
|
||||
:to="authStore.canAccessAdmin ? '/admin/announcements' : '/dashboard/announcements'"
|
||||
class="shrink-0 text-[11px] text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
{{ text('查看全部', 'View all') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<Card class="flex min-h-0 flex-1 flex-col overflow-hidden p-0">
|
||||
<div
|
||||
v-if="displayItems.length"
|
||||
class="min-h-0 max-h-72 flex-1 overflow-y-auto overscroll-contain min-[1440px]:max-h-none"
|
||||
:aria-busy="announcementStore.loading"
|
||||
>
|
||||
<ol class="px-4 py-1">
|
||||
<li
|
||||
v-for="item in displayItems"
|
||||
:key="item.announcement.id"
|
||||
class="relative border-l border-border/60 py-3 pl-4 last:border-transparent"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="absolute -left-[3.5px] top-[18px] h-1.5 w-1.5 rounded-full ring-4 ring-card"
|
||||
:class="item.announcement.is_read ? 'bg-border' : 'bg-primary'"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full min-w-0 text-left transition hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4"
|
||||
:aria-label="`${item.announcement.title}, ${item.announcement.is_read ? text('已读', 'Read') : text('未读', 'Unread')}`"
|
||||
@click="openAnnouncement?.(item.announcement)"
|
||||
>
|
||||
<time
|
||||
:datetime="item.announcement.created_at"
|
||||
class="block text-[10px] leading-4 text-muted-foreground"
|
||||
>{{ formatTime(item.announcement.created_at) }}</time>
|
||||
<span
|
||||
translate="no"
|
||||
class="mt-1 block truncate text-xs font-medium leading-5"
|
||||
:class="item.announcement.is_read ? 'text-muted-foreground' : 'text-foreground'"
|
||||
>{{ item.announcement.title }}</span>
|
||||
<span
|
||||
translate="no"
|
||||
class="mt-0.5 line-clamp-2 text-[11px] leading-[18px] text-muted-foreground [overflow-wrap:anywhere]"
|
||||
>{{ item.summary }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="announcementStore.loading"
|
||||
role="status"
|
||||
class="flex min-h-28 flex-1 items-center justify-center gap-2 px-4 py-6 text-xs text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ text('加载中', 'Loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex min-h-28 flex-1 flex-col items-center justify-center gap-2 px-4 py-6 text-xs text-muted-foreground"
|
||||
>
|
||||
<p>{{ announcementStore.error ? text('公告暂时无法加载', 'Announcements are unavailable') : text('暂无公告', 'No announcements') }}</p>
|
||||
<button
|
||||
v-if="announcementStore.error"
|
||||
type="button"
|
||||
class="underline underline-offset-4 hover:text-foreground"
|
||||
@click="announcementStore.loadList()"
|
||||
>
|
||||
{{ text('重新加载', 'Reload') }}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import { marked } from 'marked'
|
||||
import { Card } from '@/components/ui'
|
||||
import { openAnnouncementKey } from '@/components/common/announcementContext'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAnnouncementStore } from '@/stores/announcements'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const announcementStore = useAnnouncementStore()
|
||||
const openAnnouncement = inject(openAnnouncementKey, undefined)
|
||||
const { locale } = useI18n()
|
||||
const text = (zh: string, en: string) => locale.value === 'zh-CN' ? zh : en
|
||||
const displayItems = computed(() => announcementStore.items.map(announcement => {
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = sanitizeMarkdown(marked.parse(announcement.content, { async: false }))
|
||||
return { announcement, summary: (template.content.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 160) }
|
||||
}))
|
||||
|
||||
function formatTime(value: string) {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString(locale.value, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void announcementStore.loadList()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<section class="space-y-3 border-y py-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<RouterLink
|
||||
to="/admin/health-monitor?window=1h"
|
||||
class="inline-flex items-center gap-1 text-sm font-semibold hover:text-primary"
|
||||
>
|
||||
{{ t('服务健康', 'Service health') }}<ArrowUpRight class="h-3.5 w-3.5" />
|
||||
</RouterLink><span class="text-xs text-muted-foreground">{{ t('全站 API 格式 · 近 1 小时', 'All API formats · Last hour') }}</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="loading && !data"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('加载中', 'Loading') }}
|
||||
</p>
|
||||
<div
|
||||
v-else-if="error"
|
||||
role="alert"
|
||||
class="flex items-center gap-2 text-xs text-destructive"
|
||||
>
|
||||
<span>{{ t('健康数据暂不可用', 'Health data unavailable') }}</span><button
|
||||
class="underline"
|
||||
@click="refresh"
|
||||
>
|
||||
{{ t('重试', 'Retry') }}
|
||||
</button>
|
||||
</div>
|
||||
<dl
|
||||
v-if="data"
|
||||
class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4"
|
||||
>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ t('总体状态', 'Overall status') }}
|
||||
</dt><dd
|
||||
class="mt-1 font-semibold"
|
||||
:class="data.data.status === 'healthy' ? 'text-emerald-700 dark:text-emerald-400' : data.data.status === 'unknown' ? 'text-muted-foreground' : 'text-destructive'"
|
||||
>
|
||||
{{ status }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ t('服务可用率', 'Service availability') }}
|
||||
</dt><dd class="mt-1 font-semibold tabular-nums">
|
||||
{{ percent(data.data.requests.service_availability.value) }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ t('异常对象', 'Affected objects') }}
|
||||
</dt><dd class="mt-1 font-semibold tabular-nums">
|
||||
{{ count(data.data.degraded_count + data.data.unavailable_count) }} / {{ count(data.data.object_count) }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ t('状态未知', 'Unknown status') }}
|
||||
</dt><dd class="mt-1 font-semibold tabular-nums">
|
||||
{{ count(data.data.unknown_count) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p
|
||||
v-if="data"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('更新', 'Updated') }} {{ timestamp(data.meta.generated_at) }} · {{ data.meta.freshness === 'current' ? t('采集正常', 'Current') : data.meta.freshness === 'stale' ? t('采集滞后', 'Delayed') : t('采集进度未知', 'Collection progress unknown') }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ArrowUpRight } from 'lucide-vue-next'
|
||||
import { getAdminHealthSummary } from '@/api/endpoints/health-v2'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { count, percent, timestamp } from '../format'
|
||||
const props = defineProps<{ revision: number }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { data, loading, error, refresh } = useOverviewRequest(() => props.revision, signal => getAdminHealthSummary({ kind: 'api_format', window: '1h' }, signal), { scopeKey: () => 'api_format:1h' })
|
||||
const status = computed(() => ({ healthy: t('正常', 'Healthy'), degraded: t('降级', 'Degraded'), unavailable: t('不可用', 'Unavailable'), unknown: t('未知', 'Unknown') })[data.value?.data.status || 'unknown'])
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<section class="space-y-3 border-b pb-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('较前一等长时段', 'Versus previous equal period') }}
|
||||
</h2><span
|
||||
v-if="data"
|
||||
class="text-xs text-muted-foreground"
|
||||
>{{ timestamp(data.meta.range.from, range.timezone) }} - {{ timestamp(data.meta.range.to, range.timezone) }}</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="error"
|
||||
role="alert"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
{{ t('对比数据暂不可用', 'Comparison unavailable') }}
|
||||
</p><p
|
||||
v-else-if="loading && !data"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('加载中', 'Loading') }}
|
||||
</p>
|
||||
<dl
|
||||
v-if="data"
|
||||
class="grid grid-cols-2 gap-4 sm:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
v-for="item in changes"
|
||||
:key="item.label"
|
||||
>
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</dt><dd class="mt-1 text-sm font-semibold tabular-nums">
|
||||
{{ item.value }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p
|
||||
v-if="data"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('对比更新', 'Comparison updated') }} {{ timestamp(data.meta.generated_at, range.timezone) }} · {{ data.meta.coverage.status === 'complete' ? t('数据完整', 'Complete') : t('部分数据', 'Partial data') }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { overviewApi, type OverviewMetrics, type OverviewRange } from '@/api/overview'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { timestamp } from '../format'
|
||||
const props = defineProps<{ current: OverviewMetrics; range: OverviewRange; revision: number }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const previousRange = computed(() => ({ from: new Date(Date.parse(props.range.from) - (Date.parse(props.range.to) - Date.parse(props.range.from))).toISOString(), to: props.range.from, timezone: props.range.timezone }))
|
||||
const { data, loading, error } = useOverviewRequest(() => [previousRange.value, props.revision], signal => overviewApi.summary(previousRange.value, signal), { scopeKey: () => JSON.stringify(previousRange.value) })
|
||||
function delta(current: number | null | undefined, previous: number | null | undefined) {
|
||||
if (current == null || previous == null) return '-'
|
||||
if (!previous) return current ? t('前期为零', 'Previous period zero') : '0.0%'
|
||||
const value = (current - previous) / previous * 100
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}%`
|
||||
}
|
||||
const changes = computed(() => {
|
||||
const previous = data.value?.data
|
||||
return [{ label: t('请求数', 'Requests'), value: delta(props.current.request_count, previous?.request_count) }, { label: 'Tokens', value: delta(props.current.total_tokens, previous?.total_tokens) }, { label: t('计费消耗', 'Billable consumption'), value: delta(props.current.billable_amount.value == null ? null : Number(props.current.billable_amount.value), previous?.billable_amount.value == null ? null : Number(previous.billable_amount.value)) }, { label: t('有使用成员', 'Members with usage'), value: delta(props.current.usage_active_users, previous?.usage_active_users) }]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<dl class="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
:data-metric="item.key"
|
||||
class="min-w-0 rounded-lg border bg-card p-3 sm:p-5"
|
||||
>
|
||||
<dt class="flex min-h-5 items-start justify-between gap-2 text-xs font-medium text-muted-foreground">
|
||||
<span class="break-words">{{ item.label }}</span>
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:class="item.color"
|
||||
/>
|
||||
</dt>
|
||||
<dd class="mt-4 min-h-7 font-semibold tabular-nums">
|
||||
<MetricValue :value="item.value" />
|
||||
</dd>
|
||||
<p
|
||||
v-if="item.note"
|
||||
class="mt-1 text-xs text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
{{ item.note }}
|
||||
</p>
|
||||
<div class="mt-3 border-t pt-3 text-xs text-muted-foreground">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-1">
|
||||
<dt>{{ item.totalLabel }}</dt>
|
||||
<dd class="break-all font-medium tabular-nums text-foreground">
|
||||
{{ item.totalValue }}
|
||||
</dd>
|
||||
</div>
|
||||
<p
|
||||
v-if="item.totalNote"
|
||||
class="mt-1 text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
{{ item.totalNote }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Activity, Coins, Users, Zap } from 'lucide-vue-next'
|
||||
import type { OverviewDashboard } from '@/api/overview'
|
||||
import MetricValue from '../components/MetricValue.vue'
|
||||
import { count, money } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { amountStatus, amountValue } from './amount'
|
||||
|
||||
const props = defineProps<{ data: OverviewDashboard }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const items = computed(() => {
|
||||
const today = props.data.today.data
|
||||
const total = props.data.total.data
|
||||
const cost = (amount: typeof today.billable_amount) => amountValue(amount) === null ? '-' : money(amount)
|
||||
return [
|
||||
{ key: 'requests', label: t('今日请求', 'Today\'s requests'), value: count(today.request_count), totalLabel: t('总请求', 'Total requests'), totalValue: count(total.request_count), icon: Activity, color: 'text-blue-600 dark:text-blue-400' },
|
||||
{ key: 'tokens', label: t('今日 Token', 'Today\'s tokens'), value: count(today.total_tokens), totalLabel: t('总 Token', 'Total tokens'), totalValue: count(total.total_tokens), icon: Zap, color: 'text-violet-600 dark:text-violet-400' },
|
||||
{ key: 'cost', label: t('今日费用', 'Today\'s cost'), value: cost(today.billable_amount), note: amountStatus(today.billable_amount, t), totalLabel: t('总费用', 'Total cost'), totalValue: cost(total.billable_amount), totalNote: amountStatus(total.billable_amount, t), icon: Coins, color: 'text-emerald-600 dark:text-emerald-400' },
|
||||
{ key: 'employees', label: t('今日活跃成员', 'Active members today'), value: count(today.usage_active_users), totalLabel: t('启用成员', 'Enabled members'), totalValue: count(today.enabled_users ?? total.enabled_users), icon: Users, color: 'text-amber-600 dark:text-amber-400' },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { OverviewAmount } from '@/api/overview'
|
||||
|
||||
export function amountValue(amount: OverviewAmount | null | undefined): number | null {
|
||||
if (amount?.value == null || amount.status === 'unknown') return null
|
||||
const value = Number(amount.value)
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
export function amountStatus(amount: OverviewAmount | null | undefined, t: (zh: string, en: string) => string): string {
|
||||
if (amountValue(amount) === null) return t('金额未知', 'Amount unknown')
|
||||
if (amount?.status === 'known_subtotal') return t('已知小计', 'Known subtotal')
|
||||
if (amount?.status === 'estimated_subtotal') return t('已知范围估算', 'Estimate within known coverage')
|
||||
if (amount?.status === 'estimated') return t('估算', 'Estimated')
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { OverviewBreakdown, OverviewDashboardCharts, OverviewRange } from '@/api/overview'
|
||||
import { getI18nLocale } from '@/i18n'
|
||||
import { zonedInput, zonedInstant } from '../query'
|
||||
import { amountValue } from './amount'
|
||||
|
||||
export const chartColors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4', '#94a3b8']
|
||||
type ModelRow = OverviewDashboardCharts['models'][number]
|
||||
|
||||
export function chartDate(value: string, timezone: string): string {
|
||||
return new Intl.DateTimeFormat(getI18nLocale(), { timeZone: timezone, month: 'short', day: 'numeric' }).format(new Date(value))
|
||||
}
|
||||
|
||||
function knownSum(rows: { billable_amount: OverviewBreakdown['billable_amount'] }[]): number | null {
|
||||
if (!rows.length) return 0
|
||||
const values = rows.map(row => amountValue(row.billable_amount)).filter((value): value is number => value !== null)
|
||||
return values.length ? values.reduce((sum, value) => sum + value, 0) : null
|
||||
}
|
||||
|
||||
export function modelDatasets(data: OverviewDashboardCharts, unknownLabel: string, otherLabel: string) {
|
||||
const groups = new Map<string | null, ModelRow[]>()
|
||||
for (const row of data.models) {
|
||||
const rows = groups.get(row.id)
|
||||
if (rows) rows.push(row)
|
||||
else groups.set(row.id, [row])
|
||||
}
|
||||
const sorted = [...groups].sort((a, b) => (knownSum(b[1]) ?? -1) - (knownSum(a[1]) ?? -1))
|
||||
const visible = sorted.slice(0, 6).map(([id, rows]) => ({ id, label: rows[0]?.label || unknownLabel, rows }))
|
||||
if (sorted.length > 6) visible.push({ id: '__other__', label: otherLabel, rows: sorted.slice(6).flatMap(([, rows]) => rows) })
|
||||
return visible.map((group, index) => ({
|
||||
label: group.label,
|
||||
data: data.series.map(day => knownSum(group.rows.filter(row => Date.parse(row.bucket_start) === Date.parse(day.bucket_start)))),
|
||||
backgroundColor: chartColors[index % chartColors.length],
|
||||
borderRadius: 2, stack: 'models', barPercentage: 0.6, categoryPercentage: 0.7,
|
||||
}))
|
||||
}
|
||||
|
||||
export function providerSlices(rows: OverviewBreakdown[], unknownLabel: string, otherLabel: string) {
|
||||
const known = rows.map(row => ({ label: row.label || unknownLabel, value: amountValue(row.billable_amount) }))
|
||||
.filter((row): row is { label: string; value: number } => row.value !== null && row.value > 0)
|
||||
.sort((a, b) => b.value - a.value)
|
||||
const visible = known.slice(0, 6)
|
||||
if (known.length > 6) visible.push({ label: otherLabel, value: known.slice(6).reduce((sum, row) => sum + row.value, 0) })
|
||||
return visible
|
||||
}
|
||||
|
||||
export function dayRange(bucket: string, range: OverviewRange): OverviewRange {
|
||||
const date = zonedInput(bucket, range.timezone).slice(0, 10)
|
||||
const nextDate = new Date(Date.parse(`${date}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10)
|
||||
const from = localDayStart(date, range.timezone)
|
||||
const to = localDayStart(nextDate, range.timezone)
|
||||
return {
|
||||
from: new Date(Math.max(Date.parse(from), Date.parse(range.from))).toISOString(),
|
||||
to: new Date(Math.min(Date.parse(to), Date.parse(range.to))).toISOString(),
|
||||
timezone: range.timezone,
|
||||
}
|
||||
}
|
||||
|
||||
function localDayStart(date: string, timezone: string): string {
|
||||
const midnight = Date.parse(`${date}T00:00:00Z`)
|
||||
// Some timezone transitions skip midnight; start at the first existing local minute.
|
||||
for (let minute = 0; minute <= 1440; minute++) {
|
||||
const wall = new Date(midnight + minute * 60_000).toISOString().slice(0, 16)
|
||||
const instant = zonedInstant(wall, timezone)
|
||||
if (instant) return instant
|
||||
}
|
||||
throw new Error('Unable to resolve local day boundary')
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { IntervalTimelineResponse } from '@/api/cache'
|
||||
import type { DailyStat, DailyStatsResponse, ModelBreakdown } from '@/api/dashboard'
|
||||
import type { OverviewDashboardSummary } from '@/api/overview'
|
||||
import type { DateRangeParams } from '@/features/usage/types'
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
const HOUR_MS = 3_600_000
|
||||
const modelNames = ['gpt-5.4', 'claude-sonnet-4', 'gemini-2.5-pro']
|
||||
const providerNames = ['OpenAI', 'Anthropic', 'Google']
|
||||
|
||||
function dateKey(instant: number, timezone: string): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).formatToParts(new Date(instant))
|
||||
const part = (type: string) => parts.find(item => item.type === type)?.value ?? ''
|
||||
return `${part('year')}-${part('month')}-${part('day')}`
|
||||
}
|
||||
|
||||
function shiftDate(date: string, days: number): string {
|
||||
return new Date(Date.parse(`${date}T00:00:00Z`) + days * DAY_MS).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function dayStart(date: string, timezone: string): number {
|
||||
// Locate the actual local date boundary, including midnight DST transitions.
|
||||
const nominal = Date.parse(`${date}T00:00:00Z`)
|
||||
let left = nominal - 36 * HOUR_MS
|
||||
let right = nominal + 36 * HOUR_MS
|
||||
while (left < right) {
|
||||
const middle = Math.floor((left + right) / 2)
|
||||
if (dateKey(middle, timezone) < date) left = middle + 1
|
||||
else right = middle
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
function dailyModels(date: string, today: string): ModelBreakdown[] {
|
||||
const age = Math.round((Date.parse(`${today}T00:00:00Z`) - Date.parse(`${date}T00:00:00Z`)) / DAY_MS)
|
||||
const serial = Math.floor(Date.parse(`${date}T00:00:00Z`) / DAY_MS)
|
||||
const total = age < 0 || age >= 90 ? 0 : 1800 + ((serial % 13 + 13) % 13) * 120
|
||||
const requests = [Math.floor(total * 0.5), Math.floor(total * 0.3)]
|
||||
requests.push(total - requests[0] - requests[1])
|
||||
return modelNames.map((model, index) => ({
|
||||
model, requests: requests[index], tokens: requests[index] * [1100, 1600, 850][index],
|
||||
cost: requests[index] * [3, 5, 2][index] / 100,
|
||||
}))
|
||||
}
|
||||
|
||||
function dailyRow(date: string, models: ModelBreakdown[]): DailyStat {
|
||||
const requests = models.reduce((sum, model) => sum + model.requests, 0)
|
||||
return {
|
||||
date, requests, tokens: models.reduce((sum, model) => sum + model.tokens, 0),
|
||||
cost: Number(models.reduce((sum, model) => sum + model.cost, 0).toFixed(2)),
|
||||
avg_response_time: requests ? 1.52 : 0, unique_models: requests ? models.length : 0,
|
||||
unique_providers: requests ? providerNames.length : 0, model_breakdown: models,
|
||||
}
|
||||
}
|
||||
|
||||
/** Synthetic display data only; no account, usage, or billing writes. */
|
||||
export function createDashboardDemo(timezone: string): OverviewDashboardSummary {
|
||||
const now = Date.now()
|
||||
const today = dateKey(now, timezone)
|
||||
const todayFrom = dayStart(today, timezone)
|
||||
const activityDays = Array.from({ length: 365 }, (_, index) => {
|
||||
const date = shiftDate(today, index - 364)
|
||||
return { date, requests: dailyModels(date, today).reduce((sum, model) => sum + model.requests, 0) }
|
||||
})
|
||||
const days = activityDays.slice(-90).map(day => dailyRow(day.date, dailyModels(day.date, today)))
|
||||
const current = days[days.length - 1]
|
||||
const input = Math.round(current.tokens * 0.75)
|
||||
const stream = Math.round(current.requests * 0.91)
|
||||
const amount = (value: number) => ({ value: value.toFixed(8), currency: 'USD', basis: 'billable', status: 'known' })
|
||||
const observedFrom = Math.max(todayFrom, now - 2 * HOUR_MS)
|
||||
return {
|
||||
stats_since: new Date(dayStart(shiftDate(today, -89), timezone)).toISOString(),
|
||||
generated_at: new Date(now).toISOString(), timezone,
|
||||
today_from: new Date(todayFrom).toISOString(), window_seconds: Math.floor((now - todayFrom) / 1000),
|
||||
today: {
|
||||
request_count: current.requests, input_tokens: input, output_tokens: current.tokens - input,
|
||||
total_tokens: current.tokens, billable_amount: amount(current.cost), active_users: 24,
|
||||
cache_read_tokens: Math.round(input * 0.63), cache_creation_tokens: Math.round(input * 0.12), cache_input_tokens: input,
|
||||
avg_first_byte_ms: 247, avg_response_ms: 1520, stream_requests: stream, standard_requests: current.requests - stream,
|
||||
},
|
||||
total: {
|
||||
request_count: days.reduce((sum, day) => sum + day.requests, 0),
|
||||
total_tokens: days.reduce((sum, day) => sum + day.tokens, 0),
|
||||
billable_amount: amount(days.reduce((sum, day) => sum + day.cost, 0)),
|
||||
cache_read_tokens: Math.round(days.reduce((sum, day) => sum + Math.round(day.tokens * 0.75), 0) * 0.58),
|
||||
cache_input_tokens: days.reduce((sum, day) => sum + Math.round(day.tokens * 0.75), 0),
|
||||
},
|
||||
users: { total: 63, created_today: 4, deleted_today: 1 }, consecutive_active_days: 90, active_days: 90, activity_days: activityDays,
|
||||
concurrency: {
|
||||
avg: 6.84, peak: 23, observed_from: new Date(observedFrom).toISOString(), observed_through: new Date(now).toISOString(),
|
||||
scope: 'node', coverage: observedFrom > todayFrom ? 'partial' : 'complete',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createDashboardTimelineDemo(): IntervalTimelineResponse {
|
||||
const now = Date.now()
|
||||
const userIds = ['demo-dev', 'demo-production', 'demo-sandbox']
|
||||
const points = Array.from({ length: 108 }, (_, index) => ({
|
||||
x: new Date(now - DAY_MS + (index + 1) * DAY_MS / 109).toISOString(),
|
||||
y: Number((1.5 + (index * 17 % 240) + (index % 3) * 0.4).toFixed(1)),
|
||||
user_id: userIds[index % userIds.length], model: modelNames[index % modelNames.length],
|
||||
}))
|
||||
return {
|
||||
analysis_period_hours: 24, total_points: points.length, points,
|
||||
users: { 'demo-dev': '开发团队', 'demo-production': '生产应用', 'demo-sandbox': '测试环境' }, models: [...modelNames],
|
||||
}
|
||||
}
|
||||
|
||||
export function createDashboardDailyDemo(params: DateRangeParams): DailyStatsResponse {
|
||||
const timezone = params.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||||
const now = Date.now()
|
||||
const today = dateKey(now, timezone)
|
||||
const periodDays = params.preset === 'last90days' ? 90 : params.preset === 'last30days' ? 30
|
||||
: params.preset === 'today' || params.preset === 'yesterday' ? 1 : 7
|
||||
const preciseFrom = params.from ? Date.parse(params.from) : NaN
|
||||
const preciseTo = params.to ? Date.parse(params.to) : NaN
|
||||
const precise = Number.isFinite(preciseFrom) && Number.isFinite(preciseTo) && preciseFrom < preciseTo
|
||||
const validDate = (value: string | undefined) => value && /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) ? value : undefined
|
||||
let endDate = precise ? dateKey(preciseTo - 1, timezone)
|
||||
: validDate(params.end_date) || shiftDate(today, params.preset === 'yesterday' ? -1 : 0)
|
||||
let startDate = precise ? dateKey(preciseFrom, timezone)
|
||||
: validDate(params.start_date) || shiftDate(endDate, 1 - periodDays)
|
||||
if (startDate > endDate) [startDate, endDate] = [endDate, startDate]
|
||||
startDate = startDate < shiftDate(endDate, -365) ? shiftDate(endDate, -365) : startDate
|
||||
const days = Math.round((Date.parse(endDate) - Date.parse(startDate)) / DAY_MS) + 1
|
||||
const rows: DailyStat[] = []
|
||||
for (let index = 0; index < days; index += 1) {
|
||||
const date = shiftDate(startDate, index)
|
||||
const models = dailyModels(date, today)
|
||||
if (params.granularity !== 'hour') {
|
||||
rows.push(dailyRow(date, models))
|
||||
continue
|
||||
}
|
||||
const from = dayStart(date, timezone)
|
||||
const to = Math.min(dayStart(shiftDate(date, 1), timezone), now + 1)
|
||||
const hours = Math.max(0, Math.ceil((to - from) / HOUR_MS))
|
||||
for (let hour = 0; hour < hours; hour += 1) {
|
||||
const bucket = from + hour * HOUR_MS
|
||||
if (precise && (bucket >= preciseTo || bucket + HOUR_MS <= preciseFrom)) continue
|
||||
const buckets = models.map(model => {
|
||||
const requests = Math.floor(model.requests / hours) + (hour < model.requests % hours ? 1 : 0)
|
||||
const share = model.requests ? requests / model.requests : 0
|
||||
return { ...model, requests, tokens: Math.round(model.tokens * share), cost: Number((model.cost * share).toFixed(2)) }
|
||||
})
|
||||
rows.push(dailyRow(new Date(bucket).toISOString(), buckets))
|
||||
}
|
||||
}
|
||||
const modelSummary = modelNames.map((model, index) => {
|
||||
const entries = rows.map(row => row.model_breakdown[index])
|
||||
const requests = entries.reduce((sum, entry) => sum + entry.requests, 0)
|
||||
const tokens = entries.reduce((sum, entry) => sum + entry.tokens, 0)
|
||||
const cost = Number(entries.reduce((sum, entry) => sum + entry.cost, 0).toFixed(2))
|
||||
return { model, requests, tokens, cost, avg_response_time: requests ? 1.52 : 0, cost_per_request: requests ? cost / requests : 0, tokens_per_request: requests ? tokens / requests : 0 }
|
||||
})
|
||||
return {
|
||||
daily_stats: rows, model_summary: modelSummary,
|
||||
provider_summary: modelSummary.map((model, index) => ({ provider: providerNames[index], requests: model.requests, tokens: model.tokens, cost: model.cost })),
|
||||
period: { start_date: startDate, end_date: endDate, days },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { OverviewAmount } from '@/api/overview'
|
||||
import { getI18nLocale } from '@/i18n'
|
||||
|
||||
export function count(value: number | null | undefined): string {
|
||||
return value == null ? '-' : new Intl.NumberFormat(getI18nLocale(), { maximumFractionDigits: 2 }).format(value)
|
||||
}
|
||||
|
||||
export function attributionLabel(kind: string | null | undefined): string {
|
||||
const zh = getI18nLocale() === 'zh-CN'
|
||||
if (kind === 'employee') return zh ? '成员账号' : 'Member account'
|
||||
if (kind === 'standalone') return zh ? '独立余额 Key' : 'Standalone balance key'
|
||||
return !kind || kind === 'unknown' ? (zh ? '未知' : 'Unknown') : kind
|
||||
}
|
||||
|
||||
export function money(amount: OverviewAmount | null | undefined): string {
|
||||
if (amount?.value == null) return '-'
|
||||
const number = Number(amount.value)
|
||||
if (!Number.isFinite(number)) return '-'
|
||||
return new Intl.NumberFormat(getI18nLocale(), { style: 'currency', currency: amount.currency || 'USD', currencyDisplay: 'narrowSymbol', minimumFractionDigits: 2, maximumFractionDigits: 6 }).format(number)
|
||||
}
|
||||
|
||||
export function percent(value: number | null | undefined): string {
|
||||
return value == null ? '-' : `${(value * 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
export function timestamp(value: string | null | undefined, timezone?: string): string {
|
||||
return !value ? '-' : new Intl.DateTimeFormat(getI18nLocale(), { dateStyle: 'short', timeStyle: 'short', timeZone: timezone }).format(new Date(value))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
export function useOverviewI18n() {
|
||||
const { locale } = useI18n()
|
||||
return { t: (zh: string, en: string) => locale.value === 'zh-CN' ? zh : en }
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ resources ? t('资源诊断', 'Resource diagnostics') : t('实时运行', 'Live runtime') }}
|
||||
</h2><p class="min-w-0 break-words text-xs text-muted-foreground">
|
||||
{{ snapshot.scope.kind === 'cluster' ? t('已知节点集合', 'Known nodes') : t('当前节点', 'Current node') }} · {{ snapshot.node_id || t('节点标识未知', 'Unknown node ID') }} · {{ timestamp(snapshot.observed_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="snapshot.unavailable_sections.length"
|
||||
class="break-words rounded-xl border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
{{ t('未采集', 'Unavailable') }}: {{ snapshot.unavailable_sections.join(', ') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="!metrics"
|
||||
class="py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('暂无资源采样', 'No resource samples') }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<Card
|
||||
v-for="group in groups"
|
||||
:key="group.title"
|
||||
class="min-w-0 p-4 sm:p-5"
|
||||
>
|
||||
<div class="mb-5 flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ group.title }}
|
||||
</h3>
|
||||
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-border/60 bg-muted/30 text-muted-foreground">
|
||||
<component
|
||||
:is="group.icon"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div><dl class="grid grid-cols-2 gap-x-5 gap-y-5">
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.label"
|
||||
class="min-w-0"
|
||||
>
|
||||
<dt class="min-h-8 break-words text-xs leading-4 text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</dt><dd class="mt-1 min-w-0 font-semibold leading-8 tabular-nums">
|
||||
<MetricValue
|
||||
:value="item.value"
|
||||
:max-font-size="24"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
<Card
|
||||
v-if="!resources && snapshot.resilience"
|
||||
class="min-w-0 space-y-4 p-4 sm:p-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 class="flex items-center gap-2 text-sm font-semibold">
|
||||
<ShieldCheck
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('服务韧性', 'Resilience') }}
|
||||
</h3><RouterLink
|
||||
:to="link('/admin/health-monitor')"
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
{{ t('健康监控', 'Health monitor') }}
|
||||
</RouterLink>
|
||||
</div><dl class="grid grid-cols-2 gap-5">
|
||||
<div class="min-w-0">
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ t('已打开熔断器', 'Open circuits') }}
|
||||
</dt><dd class="mt-2 font-semibold tabular-nums">
|
||||
<MetricValue
|
||||
:value="count(snapshot.resilience.error_statistics.open_circuit_breakers)"
|
||||
:max-font-size="24"
|
||||
/>
|
||||
</dd>
|
||||
</div><div class="min-w-0">
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ t('降级凭据', 'Degraded credentials') }}
|
||||
</dt><dd class="mt-2 font-semibold tabular-nums">
|
||||
<MetricValue
|
||||
:value="count(snapshot.resilience.error_statistics.degraded_keys)"
|
||||
:max-font-size="24"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl><ul
|
||||
v-if="snapshot.resilience.recent_errors.length"
|
||||
class="divide-y border-t text-xs"
|
||||
>
|
||||
<li
|
||||
v-for="item in snapshot.resilience.recent_errors.slice(0, 8)"
|
||||
:key="item.error_id"
|
||||
class="flex flex-wrap items-start justify-between gap-2 py-3"
|
||||
>
|
||||
<span class="min-w-0 break-all">{{ item.error_type }} · {{ item.context.provider_name || item.context.model || item.operation }}</span><RouterLink
|
||||
v-if="item.context.request_id"
|
||||
:to="link('/admin/usage', { request_id: item.context.request_id })"
|
||||
class="min-w-0 break-all font-mono text-primary hover:underline"
|
||||
>
|
||||
{{ item.context.request_id }}
|
||||
</RouterLink><span class="text-muted-foreground">{{ timestamp(item.timestamp) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
<Card
|
||||
v-if="resources && metrics.upstreamTargets.rows.length"
|
||||
class="min-w-0 space-y-4 p-4 sm:p-5"
|
||||
>
|
||||
<h3 class="flex items-center gap-2 text-sm font-semibold">
|
||||
<Network
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('上游准入', 'Upstream admission') }}
|
||||
</h3><div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[420px] text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-xs text-muted-foreground">
|
||||
<th class="py-2 text-left">
|
||||
{{ t('目标', 'Target') }}
|
||||
</th><th class="px-3 text-right">
|
||||
{{ t('处理中', 'In flight') }}
|
||||
</th><th class="px-3 text-right">
|
||||
{{ t('可用许可', 'Available permits') }}
|
||||
</th><th class="px-3 text-right">
|
||||
{{ t('拒绝累计', 'Rejected total') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead><tbody>
|
||||
<tr
|
||||
v-for="row in metrics.upstreamTargets.rows"
|
||||
:key="row.target"
|
||||
class="border-b"
|
||||
>
|
||||
<td class="max-w-64 break-words py-2">
|
||||
{{ row.target }}
|
||||
</td><td class="px-3 text-right">
|
||||
{{ count(row.inFlight) }}
|
||||
</td><td class="px-3 text-right">
|
||||
{{ count(row.availablePermits) }}
|
||||
</td><td class="px-3 text-right">
|
||||
{{ count(row.rejectedTotal) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Activity, Cpu, Database, Layers, ListChecks, ListTodo, Network, Radio, ShieldCheck } from 'lucide-vue-next'
|
||||
import { Card } from '@/components/ui'
|
||||
import { buildGatewayMetricsSummary } from '@/api/monitoring'
|
||||
import type { OverviewLive } from '@/api/overview'
|
||||
import MetricValue from '../components/MetricValue.vue'
|
||||
import { useOverviewQuery } from '../query'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { count, timestamp } from '../format'
|
||||
const props = defineProps<{ snapshot: OverviewLive; resources?: boolean }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { link } = useOverviewQuery()
|
||||
const metrics = computed(() => props.snapshot.metrics || (props.snapshot.metrics_text ? buildGatewayMetricsSummary(props.snapshot.metrics_text) : null))
|
||||
function bytes(value: number | null | undefined) { return value == null ? '-' : `${count(value / 1024 / 1024)} MiB` }
|
||||
function basis(value: number | null | undefined) { return value == null ? '-' : `${count(value / 100)}%` }
|
||||
const groups = computed(() => {
|
||||
const m = metrics.value
|
||||
if (!m) return []
|
||||
const runtime = [
|
||||
{ title: t('请求准入', 'Request admission'), icon: Activity, items: [{ label: t('本机处理中', 'Node in flight'), value: count(m.local.inFlight) }, { label: t('本机可用许可', 'Node available permits'), value: count(m.local.availablePermits) }, { label: t('全局处理中', 'Global in flight'), value: count(m.distributed.inFlight) }, { label: t('全局可用许可', 'Global available permits'), value: count(m.distributed.availablePermits) }] },
|
||||
{ title: t('代理通道', 'Proxy tunnels'), icon: Network, items: [{ label: t('连接数', 'Connections'), value: count(m.tunnel.proxyConnections) }, { label: t('活跃流', 'Active streams'), value: count(m.tunnel.activeStreams) }, { label: t('队列深度', 'Queue depth'), value: count(m.tunnel.outboundQueueDepthTotal) }, { label: t('队列容量', 'Queue capacity'), value: count(m.tunnel.outboundQueueCapacityTotal) }] },
|
||||
{ title: t('用量写入', 'Usage writes'), icon: ListTodo, items: [{ label: t('待确认', 'Awaiting acknowledgement'), value: count(m.usageQueue.groupPending) }, { label: t('待消费', 'Awaiting delivery'), value: count(m.usageQueue.groupLag) }, { label: t('死信', 'Dead letters'), value: count(m.usageQueue.dlqLength) }, { label: t('最旧待处理 (ms)', 'Oldest pending (ms)'), value: count(m.usageQueue.oldestPendingIdleMs) }] },
|
||||
]
|
||||
if (!props.resources) return runtime
|
||||
return [
|
||||
{ title: t('进程与主机', 'Process and host'), icon: Cpu, items: [{ label: t('进程 CPU', 'Process CPU'), value: basis(m.process.processCpuUsageBasisPoints) }, { label: t('主机 CPU', 'Host CPU'), value: basis(m.process.systemCpuUsageBasisPoints) }, { label: t('进程内存', 'Process memory'), value: bytes(m.process.processMemoryBytes) }, { label: t('主机内存', 'Host memory'), value: basis(m.process.systemMemoryUsageBasisPoints) }, { label: t('打开文件', 'Open file descriptors'), value: count(m.process.openFds) }] },
|
||||
{ title: 'PostgreSQL', icon: Database, items: [{ label: t('连接池占用', 'Pool checked out'), value: `${count(m.databasePool.checkedOut)} / ${count(m.databasePool.max)}` }, { label: t('数据库等待', 'Database waits'), value: count(m.postgres.waitingConnections) }, { label: t('等待锁', 'Lock waits'), value: count(m.postgres.lockWaitingConnections) }, { label: t('最长查询 (ms)', 'Oldest query (ms)'), value: count(m.postgres.oldestActiveQueryAgeMs) }, { label: t('块缓存命中', 'Block cache hit'), value: basis(m.postgres.blockCacheHitRateBasisPoints) }] },
|
||||
{ title: 'Redis', icon: Radio, items: [{ label: t('已用内存', 'Used memory'), value: bytes(m.redisRuntime.usedMemoryBytes) }, { label: t('阻塞客户端', 'Blocked clients'), value: count(m.redisRuntime.blockedClients) }, { label: t('命中率', 'Hit rate'), value: basis(m.redisRuntime.keyspaceHitRateBasisPoints) }, { label: t('操作 / 秒', 'Operations / second'), value: count(m.redisRuntime.instantaneousOpsPerSec) }, { label: t('淘汰累计', 'Evictions total'), value: count(m.redisRuntime.evictedKeysTotal) }] },
|
||||
{ title: t('后台任务与调度', 'Tasks and scheduling'), icon: Layers, items: [{ label: t('活跃任务', 'Active tasks'), value: count(m.backgroundTasks.active) }, { label: t('异常退出累计', 'Unexpected exits total'), value: count(m.backgroundTasks.unexpectedExitsTotal) }, { label: t('异步任务', 'Async tasks'), value: count(m.tokioRuntime.aliveTasks) }, { label: t('调度队列', 'Scheduling queue'), value: count(m.tokioRuntime.globalQueueDepth) }] },
|
||||
{ title: t('审计计数', 'Audit counters'), icon: ListChecks, items: [{ label: t('待刷新', 'Pending flush'), value: count(m.usageCounter.pendingRows) }, { label: t('最旧待处理 (s)', 'Oldest pending (s)'), value: count(m.usageCounter.oldestPendingAgeSeconds) }, { label: t('失败批次累计', 'Failed batches total'), value: count(m.usageCounter.flushFailedBatchesTotal) }, { label: t('候选队列', 'Candidate queue'), value: count(m.requestCandidateQueue.depth) }] },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<Card
|
||||
class="min-w-0 overflow-hidden p-3 sm:p-4"
|
||||
:data-operations-metric="label"
|
||||
>
|
||||
<div class="flex h-5 min-w-0 items-center justify-between gap-2">
|
||||
<p
|
||||
class="truncate text-xs font-semibold text-muted-foreground"
|
||||
:title="label"
|
||||
>
|
||||
{{ label }}
|
||||
</p>
|
||||
<component
|
||||
:is="icon"
|
||||
class="h-4 w-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex h-9 min-w-0 items-center">
|
||||
<MetricValue
|
||||
:value="value"
|
||||
:max-font-size="30"
|
||||
class="w-full font-semibold leading-none tabular-nums text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="note"
|
||||
class="mt-1 truncate text-xs leading-5 text-muted-foreground"
|
||||
:title="note"
|
||||
>
|
||||
{{ note }}
|
||||
</p>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { Card } from '@/components/ui'
|
||||
import MetricValue from '../components/MetricValue.vue'
|
||||
|
||||
defineProps<{ label: string; value: string; icon: Component; note?: string }>()
|
||||
</script>
|
||||
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<Card
|
||||
class="min-w-0 overflow-hidden"
|
||||
:data-performance-analysis="dimension"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 px-4 pt-4 sm:px-5">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="rows !== null"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ count(rows.length) }} {{ dimension === 'provider' ? t('个提供商', 'providers') : t('个模型', 'models') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mx-4 my-4 grid grid-cols-2 divide-x rounded-xl bg-muted/40 sm:mx-5">
|
||||
<div
|
||||
v-for="insight in insightCards"
|
||||
:key="insight.key"
|
||||
class="min-w-0 px-3 py-3"
|
||||
:data-analysis-insight="insight.key"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ insight.label }}
|
||||
<span v-if="insight.value.partial"> · {{ t('已知数据', 'Known data') }}</span>
|
||||
</p>
|
||||
<p
|
||||
class="mt-1 truncate text-sm font-semibold"
|
||||
:title="insight.value.row?.label"
|
||||
>
|
||||
{{ insight.value.row?.label || insightState(insight.value.state) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs tabular-nums text-muted-foreground">
|
||||
<template v-if="insight.value.row">
|
||||
<template v-if="insight.key === 'failures'">
|
||||
{{ count(insight.value.row.failedCount) }} {{ t('次', 'failures') }} ·
|
||||
</template>
|
||||
{{ insight.key === 'traffic' ? t('占总请求', 'Of requests') : t('占总失败', 'Of failures') }} {{ percentage(insight.value.share) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('所选时段', 'Selected range') }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[400px] overflow-auto px-4 pb-2 sm:px-5">
|
||||
<table class="w-full min-w-[660px] table-fixed text-sm">
|
||||
<thead class="sticky top-0 z-10 bg-card text-xs text-muted-foreground">
|
||||
<tr class="border-b">
|
||||
<th class="w-[22%] py-3 pr-3 text-left font-medium">
|
||||
{{ dimension === 'provider' ? t('提供商', 'Provider') : t('模型', 'Model') }}
|
||||
</th>
|
||||
<th class="w-[16%] px-2 py-3 text-right font-medium">
|
||||
{{ t('请求量', 'Requests') }}
|
||||
</th>
|
||||
<th class="w-[16%] px-2 py-3 text-right font-medium">
|
||||
{{ t('成功率', 'Success rate') }}
|
||||
</th>
|
||||
<th
|
||||
class="w-[10%] px-2 py-3 text-right font-medium"
|
||||
:title="t('当前节点正在执行的 HTTP 与 Responses WebSocket 请求,流式请求计至结束', 'HTTP and Responses WebSocket requests executing on this node; streams count until they end')"
|
||||
>
|
||||
{{ t('当前并发', 'Concurrency') }}
|
||||
<span class="block text-[10px] font-normal">{{ t('当前节点', 'Current node') }}</span>
|
||||
</th>
|
||||
<th
|
||||
class="w-[10%] px-2 py-3 text-right font-medium"
|
||||
:title="rpmWindowTitle"
|
||||
>
|
||||
RPM
|
||||
<span class="block text-[10px] font-normal">{{ t('近 60 秒', 'Last 60s') }}</span>
|
||||
</th>
|
||||
<th class="w-[13%] px-2 py-3 text-right font-medium">
|
||||
{{ t('平均首字节', 'Avg first byte') }}
|
||||
</th>
|
||||
<th class="w-[13%] py-3 pl-2 text-right font-medium">
|
||||
{{ t('输出速度', 'Output speed') }}
|
||||
<span class="block text-[10px] font-normal">Tokens/s</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
<tr
|
||||
v-for="row in rankedRows"
|
||||
:key="`${row.id ?? ''}:${row.label}`"
|
||||
:data-analysis-row="row.id ?? row.label"
|
||||
>
|
||||
<td class="break-words py-3 pr-3 [overflow-wrap:anywhere]">
|
||||
<RouterLink
|
||||
v-if="row.id !== null"
|
||||
:to="records(row.id)"
|
||||
:data-analysis-entity-link="row.id"
|
||||
class="font-medium hover:text-primary hover:underline"
|
||||
>
|
||||
{{ row.label }}
|
||||
</RouterLink>
|
||||
<span v-else>{{ row.label }}</span>
|
||||
</td>
|
||||
<td class="px-2 py-3 text-right tabular-nums">
|
||||
{{ count(row.requestCount) }}
|
||||
<div class="mt-1 flex items-center justify-end gap-2 text-[11px] text-muted-foreground">
|
||||
<span class="h-1 w-9 overflow-hidden rounded-full bg-muted">
|
||||
<span
|
||||
class="block h-full rounded-full bg-primary/45"
|
||||
:style="{ width: `${analysisShare(row.requestCount, totalRequests) ?? 0}%` }"
|
||||
/>
|
||||
</span>
|
||||
<span>{{ percentage(analysisShare(row.requestCount, totalRequests)) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 py-3 text-right tabular-nums">
|
||||
<span data-analysis-success-rate>{{ percentage(row.successRate) }}</span>
|
||||
<div
|
||||
class="mt-1 text-[11px]"
|
||||
:class="(measuredCount(row.failedCount) ?? 0) > 0 ? 'text-amber-700 dark:text-amber-400' : 'text-muted-foreground'"
|
||||
>
|
||||
<RouterLink
|
||||
v-if="row.id !== null && measuredCount(row.failedCount) !== null"
|
||||
:to="records(row.id, true)"
|
||||
:data-analysis-failure-link="row.id"
|
||||
class="hover:underline"
|
||||
:aria-label="`${row.label} ${t('失败请求', 'failed requests')}`"
|
||||
>
|
||||
{{ t('失败', 'Failed') }} {{ count(row.failedCount) }}
|
||||
</RouterLink>
|
||||
<span v-else>{{ t('失败', 'Failed') }} {{ count(row.failedCount) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="px-2 py-3 text-right tabular-nums"
|
||||
data-analysis-concurrency
|
||||
>
|
||||
{{ count(row.concurrency ?? null) }}
|
||||
</td>
|
||||
<td
|
||||
class="px-2 py-3 text-right tabular-nums"
|
||||
data-analysis-rpm
|
||||
:title="rpmWindowTitle"
|
||||
>
|
||||
{{ count(row.rpm ?? null) }}
|
||||
</td>
|
||||
<td class="px-2 py-3 text-right tabular-nums">
|
||||
{{ duration(row.firstByteMs) }}
|
||||
</td>
|
||||
<td class="py-3 pl-2 text-right tabular-nums">
|
||||
{{ count(row.outputTps) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!rankedRows.length">
|
||||
<td
|
||||
colspan="7"
|
||||
class="py-10 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
{{ rows === null ? t('暂未取得分析数据', 'Analysis data unavailable') : totalRequests === 0 ? t('所选时段暂无请求', 'No requests in this range') : t('所选时段暂无分组数据', 'No grouped data in this range') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Card } from '@/components/ui'
|
||||
import { count as formatCount } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { useOverviewQuery } from '../query'
|
||||
import { analysisShare, analyzeRows, measuredCount, rankAnalysisRows, type AnalysisInsight, type AnalysisRow } from './analysisHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
dimension: 'provider' | 'model'
|
||||
rows: AnalysisRow[] | null
|
||||
totalRequests: number | null
|
||||
totalFailures: number | null
|
||||
liveWindowSeconds?: number
|
||||
}>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { link } = useOverviewQuery('today')
|
||||
const rpmWindowTitle = computed(() => props.liveWindowSeconds !== undefined && props.liveWindowSeconds < 60
|
||||
? t(`当前节点启动后已观测 ${formatCount(props.liveWindowSeconds)} 秒,按实际请求计数`, `Current node: ${formatCount(props.liveWindowSeconds)} seconds observed since startup; actual request count`)
|
||||
: t('当前节点最近 60 秒进入上游执行的 HTTP 与 Responses WebSocket 请求数,同一请求重试去重', 'HTTP and Responses WebSocket requests entering upstream execution on this node in the last 60 seconds; retries deduplicated'))
|
||||
const rankedRows = computed(() => rankAnalysisRows(props.rows ?? []))
|
||||
const insights = computed(() => analyzeRows(props.rows, props.totalRequests, props.totalFailures))
|
||||
const insightCards = computed(() => [
|
||||
{ key: 'traffic', label: t('流量最多', 'Most traffic'), value: insights.value.traffic },
|
||||
{ key: 'failures', label: t('失败最多', 'Most failures'), value: insights.value.failures },
|
||||
])
|
||||
|
||||
function records(id: string, failed = false) {
|
||||
return link('/admin/usage', { [props.dimension === 'provider' ? 'provider_id' : 'model']: id, ...(failed ? { status: 'failed' } : {}) })
|
||||
}
|
||||
function count(value: number | null) {
|
||||
const measured = measuredCount(value)
|
||||
return formatCount(measured)
|
||||
}
|
||||
function percentage(value: number | null) {
|
||||
const measured = measuredCount(value)
|
||||
return measured === null || measured > 100 ? '-' : `${count(measured)}%`
|
||||
}
|
||||
function duration(value: number | null) {
|
||||
const measured = measuredCount(value)
|
||||
if (measured === null) return '-'
|
||||
return measured >= 1000 ? `${count(measured / 1000)} s` : `${count(measured)} ms`
|
||||
}
|
||||
function insightState(state: AnalysisInsight['state']) {
|
||||
if (state === 'empty') return t('暂无请求', 'No requests')
|
||||
if (state === 'none') return t('无失败请求', 'No failed requests')
|
||||
return t('暂无数据', 'Unavailable')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<section
|
||||
class="space-y-4"
|
||||
data-operations-details
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('请求分析', 'Request analysis') }}
|
||||
</h2>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('所选时段 · 失败请求多的优先展示', 'Selected range · Most failures first') }}
|
||||
</p>
|
||||
</div>
|
||||
<OverviewStatus
|
||||
:loading="loading && !data"
|
||||
:error="error"
|
||||
@retry="refresh"
|
||||
/>
|
||||
<div
|
||||
v-if="data"
|
||||
class="grid grid-cols-1 items-start gap-4 min-[1920px]:grid-cols-2"
|
||||
>
|
||||
<PerformanceAnalysisCard
|
||||
:title="t('提供商分析', 'Provider analysis')"
|
||||
dimension="provider"
|
||||
:rows="providerRows"
|
||||
:total-requests="data.data.summary.request_count"
|
||||
:total-failures="data.data.summary.failed_request_count ?? null"
|
||||
:live-window-seconds="activity?.observed_window_seconds"
|
||||
/>
|
||||
<PerformanceAnalysisCard
|
||||
:title="t('模型分析', 'Model analysis')"
|
||||
dimension="model"
|
||||
:rows="modelRows"
|
||||
:total-requests="data.data.summary.request_count"
|
||||
:total-failures="data.data.summary.failed_request_count ?? null"
|
||||
:live-window-seconds="activity?.observed_window_seconds"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-if="data"
|
||||
class="space-y-4"
|
||||
data-operations-charts
|
||||
>
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('趋势与分布', 'Trends and distribution') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card class="min-w-0 space-y-3 p-4 sm:p-5">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('请求与失败趋势', 'Request and failure trend') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="data.data.timeseries.length"
|
||||
class="h-64"
|
||||
>
|
||||
<LineChart :data="requestChart" />
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="flex h-64 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('此时间范围暂无请求', 'No requests in this range') }}
|
||||
</p>
|
||||
</Card>
|
||||
<Card class="min-w-0 space-y-3 p-4 sm:p-5">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('响应时间趋势', 'Response latency trend') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="data.data.timeseries.length"
|
||||
class="h-64"
|
||||
>
|
||||
<LineChart :data="latencyChart" />
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="flex h-64 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('此时间范围暂无时延样本', 'No latency samples in this range') }}
|
||||
</p>
|
||||
</Card>
|
||||
<Card class="min-w-0 space-y-3 p-4 sm:p-5 lg:col-span-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('失败原因分布', 'Failure reasons') }}
|
||||
</h3>
|
||||
<RouterLink
|
||||
:to="link('/admin/usage', { status: 'failed' })"
|
||||
class="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
{{ t('失败请求', 'Failed requests') }}<ArrowUpRight class="h-3.5 w-3.5" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div
|
||||
v-if="data.data.errors.length"
|
||||
class="min-w-0"
|
||||
:style="{ height: `${Math.max(180, Math.min(400, data.data.errors.length * 36))}px` }"
|
||||
>
|
||||
<BarChart
|
||||
:data="failureChart"
|
||||
:options="failureChartOptions"
|
||||
:stacked="false"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="flex h-32 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('此时间范围未记录失败请求', 'No failed requests recorded in this range') }}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
<details
|
||||
class="rounded-2xl border bg-card"
|
||||
data-operations-diagnostics
|
||||
>
|
||||
<summary class="cursor-pointer px-4 py-3 text-sm font-medium sm:px-5">
|
||||
{{ t('技术诊断', 'Technical diagnostics') }}
|
||||
</summary>
|
||||
<div class="space-y-6 border-t p-4 sm:p-5">
|
||||
<section
|
||||
v-if="data"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('性能诊断', 'Performance diagnostics') }}
|
||||
</h3>
|
||||
<Badge variant="outline">
|
||||
{{ t('所选时段', 'Selected range') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<OverviewMetrics
|
||||
:metrics="data.data.summary"
|
||||
mode="performance"
|
||||
/>
|
||||
<OverviewStatus :meta="data.meta" />
|
||||
</section>
|
||||
<slot name="diagnostics" />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { ChartOptions } from 'chart.js'
|
||||
import { ArrowUpRight } from 'lucide-vue-next'
|
||||
import { Badge, Card } from '@/components/ui'
|
||||
import BarChart from '@/components/charts/BarChart.vue'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
import { overviewApi, type OverviewExecutionActivity, type OverviewQuery } from '@/api/overview'
|
||||
import OverviewStatus from '../components/OverviewStatus.vue'
|
||||
import OverviewMetrics from '../components/OverviewMetrics.vue'
|
||||
import PerformanceAnalysisCard from './PerformanceAnalysisCard.vue'
|
||||
import { mergeLiveAnalysis, type AnalysisRow } from './analysisHelpers'
|
||||
import { useOverviewQuery } from '../query'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { timestamp } from '../format'
|
||||
|
||||
const props = defineProps<{ revision: number; activity?: OverviewExecutionActivity | null }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { range, relativePreset, link } = useOverviewQuery('today')
|
||||
// This overview always analyses all requests. Old diagnostic filters in links
|
||||
// must not silently hide providers or models from the automatic reports.
|
||||
const analysisQuery = computed<OverviewQuery>(() => ({
|
||||
...range.value,
|
||||
granularity: Date.parse(range.value.to) - Date.parse(range.value.from) <= 48 * 3_600_000 ? 'hour' : 'day',
|
||||
}))
|
||||
const scopeKey = computed(() => JSON.stringify(relativePreset.value
|
||||
? { timezone: range.value.timezone, granularity: analysisQuery.value.granularity, preset: relativePreset.value }
|
||||
: analysisQuery.value))
|
||||
const { data, loading, error, refresh } = useOverviewRequest(
|
||||
() => JSON.stringify([analysisQuery.value, props.revision]),
|
||||
signal => overviewApi.performance(analysisQuery.value, signal),
|
||||
{ scopeKey },
|
||||
)
|
||||
const historicalProviders = computed<AnalysisRow[] | null>(() => data.value?.data.providers?.providers.map(row => ({
|
||||
id: row.provider_id || null,
|
||||
label: row.provider || t('未归属提供商', 'Unattributed provider'),
|
||||
requestCount: row.request_count,
|
||||
failedCount: row.error_count ?? null,
|
||||
successRate: row.success_rate ?? null,
|
||||
firstByteMs: row.avg_first_byte_time_ms ?? null,
|
||||
outputTps: row.avg_output_tps ?? null,
|
||||
})) ?? null)
|
||||
const historicalModels = computed<AnalysisRow[] | null>(() => data.value?.data.models?.map(row => ({
|
||||
id: row.model || null,
|
||||
label: row.model || t('未记录模型', 'Unrecorded model'),
|
||||
requestCount: row.request_count,
|
||||
failedCount: row.error_count ?? null,
|
||||
successRate: row.success_rate ?? null,
|
||||
firstByteMs: row.avg_first_byte_time_ms ?? null,
|
||||
outputTps: row.avg_output_tps ?? null,
|
||||
})) ?? null)
|
||||
const completeActivity = computed(() => props.activity?.coverage === 'complete' && props.activity.scope.kind === 'node' ? props.activity : null)
|
||||
const providerRows = computed(() => mergeLiveAnalysis(historicalProviders.value,
|
||||
completeActivity.value?.providers.map(row => ({ id: row.provider_id || null, label: row.provider || row.provider_id || t('未归属提供商', 'Unattributed provider'), concurrency: row.current_concurrency, rpm: row.requests_per_minute })) ?? null))
|
||||
const modelRows = computed(() => mergeLiveAnalysis(historicalModels.value,
|
||||
completeActivity.value?.models.map(row => ({ id: row.model || null, label: row.model || t('未记录模型', 'Unrecorded model'), concurrency: row.current_concurrency, rpm: row.requests_per_minute })) ?? null))
|
||||
const chartLabels = computed(() => data.value?.data.timeseries.map(point => timestamp(point.bucket_start, range.value.timezone)) || [])
|
||||
const requestChart = computed(() => ({
|
||||
labels: chartLabels.value,
|
||||
datasets: [
|
||||
{ label: t('请求数', 'Requests'), data: data.value?.data.timeseries.map(point => point.request_count) || [], borderColor: '#0d9488', pointRadius: 1, borderWidth: 2, tension: 0.1 },
|
||||
{ label: t('失败请求', 'Failed requests'), data: data.value?.data.timeseries.map(point => point.failed_request_count) || [], borderColor: '#db7d60', pointRadius: 1, borderWidth: 2, tension: 0.1 },
|
||||
],
|
||||
}))
|
||||
const latencyChart = computed(() => ({
|
||||
labels: chartLabels.value,
|
||||
datasets: (['p50', 'p95', 'p99'] as const).map((key, index) => ({ label: `${key.toUpperCase()} (ms)`, data: data.value?.data.timeseries.map(point => point.latency_ms?.[key] ?? null) || [], borderColor: ['#0d9488', '#d97706', '#e11d48'][index], pointRadius: 1, borderWidth: 2, tension: 0.1 })),
|
||||
}))
|
||||
const failureChart = computed(() => {
|
||||
const failures = [...(data.value?.data.errors || [])].sort((a, b) => b.count - a.count)
|
||||
return {
|
||||
labels: failures.map(row => row.reason),
|
||||
datasets: [{ label: t('失败请求', 'Failed requests'), data: failures.map(row => row.count), backgroundColor: '#db7d60', borderRadius: 4, maxBarThickness: 24 }],
|
||||
}
|
||||
})
|
||||
const failureChartOptions: ChartOptions<'bar'> = {
|
||||
indexAxis: 'y',
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { beginAtZero: true, ticks: { precision: 0 }, grid: { color: 'rgba(156, 163, 175, 0.1)' } },
|
||||
y: { grid: { display: false } },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<section
|
||||
data-runtime-focus
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('处理链路', 'Processing pipeline') }}
|
||||
</h2>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px]"
|
||||
>
|
||||
{{ t('实时', 'Live') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-3">
|
||||
<Card
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
:data-runtime-focus-group="group.key"
|
||||
class="min-w-0 p-4"
|
||||
>
|
||||
<div class="mb-3 flex min-h-5 flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="flex items-center gap-2 text-sm font-semibold">
|
||||
<component
|
||||
:is="group.icon"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ group.title }}
|
||||
</h3>
|
||||
<RouterLink
|
||||
v-if="group.key === 'upstream'"
|
||||
:to="link('/admin/health-monitor')"
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
{{ t('健康监控', 'Health monitor') }}
|
||||
</RouterLink>
|
||||
<span
|
||||
v-if="group.key === 'usage' && queueStatus"
|
||||
data-runtime-focus-queue-state
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ queueStatus }}
|
||||
</span>
|
||||
</div>
|
||||
<dl class="divide-y divide-border/50">
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.key"
|
||||
:data-runtime-focus-item="item.key"
|
||||
class="flex items-center justify-between gap-3 py-2"
|
||||
>
|
||||
<dt
|
||||
class="text-xs text-muted-foreground"
|
||||
:title="item.description"
|
||||
>
|
||||
{{ item.label }}
|
||||
</dt>
|
||||
<dd class="shrink-0 text-sm font-semibold tabular-nums">
|
||||
{{ count(item.value) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { GitBranch, ListTodo, ShieldCheck } from 'lucide-vue-next'
|
||||
import { Badge, Card } from '@/components/ui'
|
||||
import { buildGatewayMetricsSummary } from '@/api/monitoring'
|
||||
import type { OverviewLive } from '@/api/overview'
|
||||
import { count } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { useOverviewQuery } from '../query'
|
||||
|
||||
const props = defineProps<{ snapshot: OverviewLive }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { link } = useOverviewQuery()
|
||||
const metrics = computed(() => props.snapshot.metrics || (props.snapshot.metrics_text ? buildGatewayMetricsSummary(props.snapshot.metrics_text) : null))
|
||||
const queueStatus = computed(() => {
|
||||
const queue = metrics.value?.usageQueue
|
||||
if (queue?.enabled === false) return t('未启用', 'Disabled')
|
||||
if (queue?.configured === false) return t('未配置', 'Not configured')
|
||||
if (queue?.unavailable === true || !queue || [queue.groupLag, queue.groupPending, queue.dlqLength].every(value => value == null)) return t('未采集', 'Unavailable')
|
||||
return ''
|
||||
})
|
||||
const groups = computed(() => {
|
||||
const m = metrics.value
|
||||
const upstream = props.snapshot.resilience?.error_statistics
|
||||
const postgres = m?.postgres?.unavailable === true || m?.postgres?.available === false ? undefined : m?.postgres
|
||||
const queue = queueStatus.value ? undefined : m?.usageQueue
|
||||
return [
|
||||
{
|
||||
key: 'upstream', title: t('上游可用性', 'Upstream availability'), icon: ShieldCheck,
|
||||
items: [
|
||||
{ key: 'open-circuits', label: t('打开熔断器', 'Open circuits'), value: upstream?.open_circuit_breakers },
|
||||
{ key: 'degraded-credentials', label: t('降级凭据', 'Degraded credentials'), value: upstream?.degraded_keys },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'requests', title: t('请求处理', 'Request processing'), icon: GitBranch,
|
||||
items: [
|
||||
{ key: 'candidate-queue', label: t('候选队列', 'Candidate queue'), value: m?.requestCandidateQueue?.depth },
|
||||
{ key: 'database-waiting', label: t('数据库等待', 'Database waits'), value: postgres?.waitingConnections, description: t('存在等待事件的活跃数据库会话', 'Active database sessions with a wait event') },
|
||||
{ key: 'lock-waiting', label: t('锁等待', 'Lock waits'), value: postgres?.lockWaitingConnections, description: t('数据库等待中正在等待锁的会话', 'Database sessions waiting for a lock') },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'usage', title: t('用量落盘', 'Usage persistence'), icon: ListTodo,
|
||||
items: [
|
||||
{ key: 'usage-lag', label: t('待消费', 'Awaiting delivery'), value: queue?.groupLag, description: t('尚未投递给消费组的条目', 'Entries not yet delivered to the consumer group') },
|
||||
{ key: 'usage-pending', label: t('待确认', 'Awaiting acknowledgement'), value: queue?.groupPending, description: t('已投递但尚未确认完成的条目', 'Delivered entries not yet acknowledged') },
|
||||
{ key: 'usage-dead-letters', label: t('死信', 'Dead letters'), value: queue?.dlqLength, description: t('当前死信队列中的条目', 'Entries currently retained in the dead-letter queue') },
|
||||
],
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<section
|
||||
data-operations-core
|
||||
class="space-y-3"
|
||||
>
|
||||
<OverviewStatus
|
||||
:loading="loading && !data"
|
||||
:error="error"
|
||||
@retry="refresh"
|
||||
/>
|
||||
<div
|
||||
v-if="data"
|
||||
class="grid grid-cols-1 items-stretch gap-6 lg:grid-cols-2"
|
||||
>
|
||||
<section
|
||||
data-operations-hardware
|
||||
class="flex min-w-0 flex-col gap-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('硬件性能', 'Hardware performance') }}
|
||||
</h2>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px]"
|
||||
>
|
||||
{{ t('当前节点', 'Current node') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="grid flex-1 grid-cols-2 gap-3 sm:gap-4">
|
||||
<OperationsMetricCard
|
||||
v-for="item in hardwareMetrics"
|
||||
:key="item.label"
|
||||
v-bind="item"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
data-operations-gateway
|
||||
class="flex min-w-0 flex-col gap-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('网关指标', 'Gateway metrics') }}
|
||||
</h2>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px]"
|
||||
>
|
||||
{{ t('实时', 'Live') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="grid flex-1 grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4">
|
||||
<OperationsMetricCard
|
||||
v-for="item in coreMetrics"
|
||||
:key="item.label"
|
||||
v-bind="item"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<slot :snapshot="snapshot" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Activity, CheckCircle2, Cpu, Gauge, Hash, MemoryStick, Radio, TriangleAlert } from 'lucide-vue-next'
|
||||
import { overviewApi } from '@/api/overview'
|
||||
import { buildGatewayMetricsSummary } from '@/api/monitoring'
|
||||
import { Badge } from '@/components/ui'
|
||||
import OverviewStatus from '../components/OverviewStatus.vue'
|
||||
import OperationsMetricCard from './OperationsMetricCard.vue'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { count, percent } from '../format'
|
||||
|
||||
const props = defineProps<{ revision: number }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { data, loading, error, refresh } = useOverviewRequest(() => props.revision, signal => overviewApi.live(signal), { scopeKey: () => 'live' })
|
||||
const runtime = computed(() => {
|
||||
const snapshot = data.value?.data
|
||||
return snapshot?.metrics || (snapshot?.metrics_text ? buildGatewayMetricsSummary(snapshot.metrics_text) : null)
|
||||
})
|
||||
const snapshot = computed(() => data.value ? { ...data.value.data, metrics: runtime.value } : undefined)
|
||||
function memory(value: number | null | undefined) {
|
||||
if (value == null) return '-'
|
||||
return value >= 1024 ** 3 ? `${count(value / 1024 ** 3)} GiB` : `${count(value / 1024 ** 2)} MiB`
|
||||
}
|
||||
function basis(value: number | null | undefined) {
|
||||
return value == null ? '-' : `${count(value / 100)}%`
|
||||
}
|
||||
const hardwareMetrics = computed(() => {
|
||||
const process = runtime.value?.process
|
||||
const memoryNote = process?.systemMemoryUsedBytes != null && process.systemMemoryTotalBytes != null && process.systemMemoryTotalBytes > 0
|
||||
? `${memory(process.systemMemoryUsedBytes)} / ${memory(process.systemMemoryTotalBytes)}`
|
||||
: t('内存使用率', 'Memory usage')
|
||||
return [
|
||||
{ label: t('主机 CPU', 'Host CPU'), value: basis(process?.systemCpuUsageBasisPoints), icon: Cpu, note: t('CPU 使用率', 'CPU usage') },
|
||||
{ label: t('主机内存', 'Host memory'), value: process?.systemMemoryTotalBytes === 0 ? '-' : basis(process?.systemMemoryUsageBasisPoints), icon: MemoryStick, note: memoryNote },
|
||||
{ label: t('进程 CPU', 'Process CPU'), value: basis(process?.processCpuUsageBasisPoints), icon: Cpu, note: t('100% = 1 核', '100% = 1 core') },
|
||||
{ label: t('进程内存', 'Process memory'), value: memory(process?.processMemoryBytes), icon: MemoryStick, note: t('网关进程', 'Gateway process') },
|
||||
]
|
||||
})
|
||||
const coreMetrics = computed(() => {
|
||||
const metrics = data.value?.data.recent_activity?.data
|
||||
const recentNote = t('最近 60 秒', 'Last 60 seconds')
|
||||
const nodeNote = t('当前节点', 'Current node')
|
||||
return [
|
||||
{ label: 'RPM', value: count(metrics?.requests_per_minute), icon: Activity, note: recentNote },
|
||||
{ label: 'TPM', value: count(metrics?.tokens_per_minute), icon: Hash, note: recentNote },
|
||||
{ label: t('请求成功率', 'Success rate'), value: percent(metrics?.success_rate?.value), icon: CheckCircle2, note: recentNote },
|
||||
{ label: t('失败请求', 'Failed requests'), value: count(metrics?.failed_request_count), icon: TriangleAlert, note: recentNote },
|
||||
{ label: t('当前并发', 'Concurrent requests'), value: count(runtime.value?.local.inFlight), icon: Gauge, note: nodeNote },
|
||||
{ label: t('活跃流', 'Active streams'), value: count(runtime.value?.tunnel.activeStreams), icon: Radio, note: nodeNote },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,101 @@
|
||||
export interface AnalysisRow {
|
||||
id: string | null
|
||||
label: string
|
||||
requestCount: number
|
||||
failedCount: number | null
|
||||
/** Percentage points, from 0 to 100. */
|
||||
successRate: number | null
|
||||
firstByteMs: number | null
|
||||
outputTps: number | null
|
||||
concurrency?: number | null
|
||||
rpm?: number | null
|
||||
}
|
||||
|
||||
export interface LiveAnalysisRow {
|
||||
id: string | null
|
||||
label: string
|
||||
concurrency: number
|
||||
rpm: number
|
||||
}
|
||||
|
||||
/** A complete node snapshot makes absent groups a measured zero. An unavailable
|
||||
* snapshot must never turn those same groups into zero load. Include active
|
||||
* groups before their usage rows have reached the historical report. */
|
||||
export function mergeLiveAnalysis(
|
||||
historical: readonly AnalysisRow[] | null,
|
||||
live: readonly LiveAnalysisRow[] | null,
|
||||
): AnalysisRow[] | null {
|
||||
if (historical === null) return null
|
||||
const current = new Map(live?.map(row => [row.id, row]))
|
||||
const result = historical.map(row => {
|
||||
const activity = current.get(row.id)
|
||||
current.delete(row.id)
|
||||
return { ...row, concurrency: activity?.concurrency ?? (live === null ? null : 0), rpm: activity?.rpm ?? (live === null ? null : 0) }
|
||||
})
|
||||
for (const row of current.values()) {
|
||||
result.push({
|
||||
id: row.id, label: row.label, requestCount: 0, failedCount: 0,
|
||||
successRate: null, firstByteMs: null, outputTps: null,
|
||||
concurrency: row.concurrency, rpm: row.rpm,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function measuredCount(value: number | null | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
|
||||
}
|
||||
|
||||
/** An unavailable or inconsistent denominator must not turn into a 0% share. */
|
||||
export function analysisShare(value: number | null, total: number | null): number | null {
|
||||
const part = measuredCount(value)
|
||||
const denominator = measuredCount(total)
|
||||
if (part === null || denominator === null || denominator === 0 || part > denominator) return null
|
||||
return part / denominator * 100
|
||||
}
|
||||
|
||||
function stableOrder(left: AnalysisRow, right: AnalysisRow): number {
|
||||
const leftKey = `${left.id ?? ''}\u0000${left.label}`
|
||||
const rightKey = `${right.id ?? ''}\u0000${right.label}`
|
||||
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0
|
||||
}
|
||||
|
||||
/** Known failures first, then traffic; unavailable failures follow measured zero. */
|
||||
export function rankAnalysisRows(rows: readonly AnalysisRow[]): AnalysisRow[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
const failures = (measuredCount(right.failedCount) ?? -1) - (measuredCount(left.failedCount) ?? -1)
|
||||
const traffic = (measuredCount(right.requestCount) ?? -1) - (measuredCount(left.requestCount) ?? -1)
|
||||
return failures || traffic || stableOrder(left, right)
|
||||
})
|
||||
}
|
||||
|
||||
export interface AnalysisInsight {
|
||||
state: 'ready' | 'unavailable' | 'empty' | 'none'
|
||||
row: AnalysisRow | null
|
||||
share: number | null
|
||||
partial: boolean
|
||||
}
|
||||
|
||||
export function analyzeRows(rows: readonly AnalysisRow[] | null, totalRequests: number | null, totalFailures: number | null): {
|
||||
traffic: AnalysisInsight
|
||||
failures: AnalysisInsight
|
||||
} {
|
||||
const unavailable: AnalysisInsight = { state: 'unavailable', row: null, share: null, partial: false }
|
||||
if (rows === null) return { traffic: { ...unavailable }, failures: { ...unavailable } }
|
||||
const empty: AnalysisInsight = { state: 'empty', row: null, share: null, partial: false }
|
||||
if (!rows.length && totalRequests === 0) return { traffic: { ...empty }, failures: { ...empty } }
|
||||
|
||||
const trafficRow = [...rows].sort((left, right) =>
|
||||
(measuredCount(right.requestCount) ?? -1) - (measuredCount(left.requestCount) ?? -1) || stableOrder(left, right),
|
||||
).find(row => (measuredCount(row.requestCount) ?? 0) > 0)
|
||||
const failedRow = rankAnalysisRows(rows).find(row => (measuredCount(row.failedCount) ?? 0) > 0)
|
||||
const traffic: AnalysisInsight = trafficRow
|
||||
? { state: 'ready', row: trafficRow, share: analysisShare(trafficRow.requestCount, totalRequests), partial: rows.some(row => measuredCount(row.requestCount) === null) }
|
||||
: totalRequests === 0 ? { ...empty } : { ...unavailable }
|
||||
const failures: AnalysisInsight = failedRow
|
||||
? { state: 'ready', row: failedRow, share: analysisShare(failedRow.failedCount, totalFailures), partial: rows.some(row => measuredCount(row.failedCount) === null) }
|
||||
: totalFailures === 0
|
||||
? { state: 'none', row: null, share: null, partial: false }
|
||||
: { ...unavailable }
|
||||
return { traffic, failures }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { ChartData } from 'chart.js'
|
||||
import type {
|
||||
ProviderPerformanceItem,
|
||||
ProviderPerformanceTimelineItem,
|
||||
} from '@/api/admin'
|
||||
|
||||
export type ProviderPerformanceMetricKey = 'avg_output_tps' | 'avg_first_byte_time_ms'
|
||||
|
||||
const PROVIDER_CHART_COLORS = [
|
||||
'rgb(59, 130, 246)',
|
||||
'rgb(16, 185, 129)',
|
||||
'rgb(234, 179, 8)',
|
||||
'rgb(239, 68, 68)',
|
||||
'rgb(139, 92, 246)',
|
||||
'rgb(14, 165, 233)',
|
||||
'rgb(249, 115, 22)',
|
||||
'rgb(20, 184, 166)',
|
||||
]
|
||||
|
||||
export function formatProviderPerformanceMetric(
|
||||
value: number | null | undefined,
|
||||
suffix = '',
|
||||
decimals = 2
|
||||
): string {
|
||||
if (value == null || Number.isNaN(value)) {
|
||||
return '-'
|
||||
}
|
||||
if (suffix === 'ms') {
|
||||
return formatDurationMs(value, decimals)
|
||||
}
|
||||
return `${value.toFixed(decimals)}${suffix}`
|
||||
}
|
||||
|
||||
export function formatDurationMs(
|
||||
value: number | null | undefined,
|
||||
msDecimals = 0,
|
||||
secondsDecimals = 2
|
||||
): string {
|
||||
if (value == null || Number.isNaN(value)) {
|
||||
return '-'
|
||||
}
|
||||
if (Math.abs(value) >= 1000) {
|
||||
return `${(value / 1000).toFixed(secondsDecimals)}s`
|
||||
}
|
||||
return `${value.toFixed(msDecimals)}ms`
|
||||
}
|
||||
|
||||
export function buildProviderPerformanceChartData(
|
||||
timeline: ProviderPerformanceTimelineItem[],
|
||||
metric: ProviderPerformanceMetricKey,
|
||||
providers: Pick<ProviderPerformanceItem, 'provider_id' | 'provider'>[] = []
|
||||
): ChartData<'line'> {
|
||||
const labels = Array.from(new Set(timeline.map(item => item.date)))
|
||||
const providerMap = new Map<string, string>()
|
||||
for (const provider of providers) {
|
||||
providerMap.set(provider.provider_id, provider.provider)
|
||||
}
|
||||
for (const item of timeline) {
|
||||
if (!providerMap.has(item.provider_id)) {
|
||||
providerMap.set(item.provider_id, item.provider)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: Array.from(providerMap.entries()).map(([providerId, provider], index) => {
|
||||
const byDate = new Map(
|
||||
timeline
|
||||
.filter(item => item.provider_id === providerId)
|
||||
.map(item => [item.date, item[metric]] as const)
|
||||
)
|
||||
return {
|
||||
label: provider,
|
||||
data: labels.map(label => byDate.get(label) ?? null),
|
||||
borderColor: PROVIDER_CHART_COLORS[index % PROVIDER_CHART_COLORS.length],
|
||||
tension: 0.25,
|
||||
pointRadius: 2,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { computed, inject, provide, ref, type ComputedRef, type InjectionKey } from 'vue'
|
||||
import { useRoute, useRouter, type LocationQuery, type LocationQueryRaw } from 'vue-router'
|
||||
import type { OverviewQuery, OverviewRange } from '@/api/overview'
|
||||
|
||||
interface OverviewRangeContext {
|
||||
rolling: boolean
|
||||
range: ComputedRef<OverviewRange>
|
||||
relativePreset: ComputedRef<string | null>
|
||||
refreshRange: () => void
|
||||
}
|
||||
const overviewRangeKey: InjectionKey<OverviewRangeContext> = Symbol('overview-range')
|
||||
const relativePresets = ['today', 'last1hour', 'last24hours', 'last7days', 'last30days', 'last90days']
|
||||
|
||||
export function queryString(query: LocationQuery, key: string): string {
|
||||
const value = query[key]
|
||||
return (Array.isArray(value) ? value[0] : value) ?? ''
|
||||
}
|
||||
|
||||
export function browserTimezone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||||
}
|
||||
|
||||
export function isTimezone(value: string): boolean {
|
||||
try { new Intl.DateTimeFormat('en-US', { timeZone: value }).format(); return true } catch { return false }
|
||||
}
|
||||
|
||||
export function zonedInput(instant: string | Date, timezone: string): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||
}).formatToParts(new Date(instant))
|
||||
const part = (key: string) => parts.find(p => p.type === key)?.value ?? ''
|
||||
return `${part('year')}-${part('month')}-${part('day')}T${part('hour')}:${part('minute')}`
|
||||
}
|
||||
|
||||
export function zonedInstant(value: string, timezone: string): string | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value) || !isTimezone(timezone)) return null
|
||||
const wall = Date.parse(`${value}:00Z`)
|
||||
if (!Number.isFinite(wall)) return null
|
||||
// Probe both sides of a DST transition. Gaps are invalid; repeated times choose the earlier instant.
|
||||
const offsets = new Set<number>()
|
||||
for (const hours of [-36, -12, 0, 12, 36]) {
|
||||
const probe = wall + hours * 3_600_000
|
||||
const formatted = Date.parse(`${zonedInput(new Date(probe), timezone)}:00Z`)
|
||||
offsets.add(formatted - probe)
|
||||
}
|
||||
const candidates = [...offsets].map(offset => wall - offset)
|
||||
.filter(candidate => zonedInput(new Date(candidate), timezone) === value).sort((a, b) => a - b)
|
||||
const candidate = candidates[0]
|
||||
return candidate === undefined ? null : new Date(candidate).toISOString()
|
||||
}
|
||||
|
||||
export function presetRange(preset: string, timezone = browserTimezone(), now = new Date()): OverviewRange {
|
||||
now = new Date(Math.floor(now.getTime() / 1000) * 1000)
|
||||
let start = now.getTime() - 7 * 86_400_000
|
||||
if (preset === 'last1hour') start = now.getTime() - 3_600_000
|
||||
if (preset === 'last24hours') start = now.getTime() - 86_400_000
|
||||
if (preset === 'last30days') start = now.getTime() - 30 * 86_400_000
|
||||
if (preset === 'last90days') start = now.getTime() - 90 * 86_400_000
|
||||
if (preset === 'today') {
|
||||
const midnight = zonedInstant(`${zonedInput(now, timezone).slice(0, 10)}T00:00`, timezone)
|
||||
if (midnight) start = Date.parse(midnight)
|
||||
}
|
||||
return { from: new Date(start).toISOString(), to: now.toISOString(), timezone }
|
||||
}
|
||||
|
||||
export function rangeFromQuery(query: LocationQuery, fallback: OverviewRange): OverviewRange {
|
||||
const from = queryString(query, 'from')
|
||||
const to = queryString(query, 'to')
|
||||
const timezone = queryString(query, 'timezone') || fallback.timezone
|
||||
if (from && to && Number.isFinite(Date.parse(from)) && Date.parse(from) < Date.parse(to) && isTimezone(timezone)) {
|
||||
return { from: new Date(from).toISOString(), to: new Date(to).toISOString(), timezone }
|
||||
}
|
||||
if (!from && !to && isTimezone(timezone)) {
|
||||
const startDate = queryString(query, 'start_date')
|
||||
const endDate = queryString(query, 'end_date')
|
||||
const shiftDate = (date: string, days: number) => new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10)
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(startDate) && /^\d{4}-\d{2}-\d{2}$/.test(endDate) && Number.isFinite(Date.parse(`${endDate}T00:00:00Z`))) {
|
||||
const start = zonedInstant(`${startDate}T00:00`, timezone)
|
||||
const end = zonedInstant(`${shiftDate(endDate, 1)}T00:00`, timezone)
|
||||
if (start && end && start < end) return { from: start, to: end, timezone }
|
||||
}
|
||||
const preset = queryString(query, 'preset')
|
||||
const legacyDays: Record<string, number> = { today: 1, yesterday: 1, last7days: 7, last30days: 30, last90days: 90 }
|
||||
if (legacyDays[preset]) {
|
||||
const today = zonedInput(new Date(fallback.to), timezone).slice(0, 10)
|
||||
const endDate = preset === 'yesterday' ? today : shiftDate(today, 1)
|
||||
const start = zonedInstant(`${shiftDate(endDate, -legacyDays[preset])}T00:00`, timezone)
|
||||
const end = zonedInstant(`${endDate}T00:00`, timezone)
|
||||
if (start && end) return { from: start, to: end, timezone }
|
||||
}
|
||||
if (['last1hour', 'last24hours'].includes(preset)) return presetRange(preset, timezone, new Date(fallback.to))
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function rangeQuery(range: OverviewRange): LocationQueryRaw {
|
||||
return { from: range.from, to: range.to, timezone: range.timezone }
|
||||
}
|
||||
|
||||
export function usageQuery(query: OverviewQuery): OverviewQuery {
|
||||
const { search: _search, account_status: _account, usage_status: _usage, sort: _sort, order: _order, offset: _offset, limit: _limit, group_by: _group, ...filters } = query
|
||||
return filters
|
||||
}
|
||||
|
||||
export function useOverviewQuery(defaultPreset = 'last7days', options: { rolling?: boolean } = {}) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const inheritedRange = inject(overviewRangeKey, null)
|
||||
const rolling = inheritedRange?.rolling ?? options.rolling ?? false
|
||||
const initialRange = presetRange(defaultPreset)
|
||||
const refreshedAt = ref(Date.parse(initialRange.to))
|
||||
const relativePreset = inheritedRange?.relativePreset ?? computed(() => {
|
||||
if (!rolling) return null
|
||||
const selected = queryString(route.query, 'relative_preset')
|
||||
if (relativePresets.includes(selected)) return selected
|
||||
if (['from', 'to', 'start_date', 'end_date'].some(key => queryString(route.query, key))) return null
|
||||
const legacyPreset = queryString(route.query, 'preset')
|
||||
return legacyPreset ? relativePresets.includes(legacyPreset) ? legacyPreset : null : defaultPreset
|
||||
})
|
||||
const range = inheritedRange?.range ?? computed(() => {
|
||||
if (!relativePreset.value) return rangeFromQuery(route.query, initialRange)
|
||||
const timezone = queryString(route.query, 'timezone') || initialRange.timezone
|
||||
const selectedAt = Date.parse(queryString(route.query, 'to'))
|
||||
return presetRange(relativePreset.value, isTimezone(timezone) ? timezone : initialRange.timezone,
|
||||
new Date(Math.max(refreshedAt.value, Number.isFinite(selectedAt) ? selectedAt : 0)))
|
||||
})
|
||||
const refreshRange = inheritedRange?.refreshRange ?? (() => {
|
||||
if (relativePreset.value) refreshedAt.value = Date.now()
|
||||
})
|
||||
provide(overviewRangeKey, { rolling, range, relativePreset, refreshRange })
|
||||
const query = computed<OverviewQuery>(() => {
|
||||
const value: OverviewQuery = { ...range.value }
|
||||
const textKeys = ['user_id', 'api_key_id', 'request_type', 'credential_owner_id', 'attribution_kind', 'model', 'provider_id', 'api_format', 'endpoint_kind', 'status', 'search', 'account_status', 'usage_status', 'sort', 'group_by', 'amount_basis'] as const
|
||||
for (const key of textKeys) {
|
||||
const text = queryString(route.query, key)
|
||||
if (text) value[key] = text
|
||||
}
|
||||
for (const key of ['is_stream', 'has_format_conversion'] as const) {
|
||||
const text = queryString(route.query, key)
|
||||
if (text === 'true' || text === 'false') value[key] = text === 'true'
|
||||
}
|
||||
for (const key of ['limit', 'offset', 'slow_threshold_ms'] as const) {
|
||||
const text = queryString(route.query, key)
|
||||
if (text && Number.isFinite(Number(text))) value[key] = Math.max(0, Math.floor(Number(text)))
|
||||
}
|
||||
value.order = queryString(route.query, 'order') === 'asc' ? 'asc' : 'desc'
|
||||
const granularity = queryString(route.query, 'granularity')
|
||||
value.granularity = granularity === 'hour' ? 'hour' : granularity === 'day' ? 'day'
|
||||
: Date.parse(range.value.to) - Date.parse(range.value.from) <= 48 * 3_600_000 ? 'hour' : 'day'
|
||||
return value
|
||||
})
|
||||
// Advancing a relative window is a refresh of the same selection, not a new filter scope.
|
||||
const scopeKey = computed(() => JSON.stringify(relativePreset.value
|
||||
? { ...query.value, from: undefined, to: undefined, relative_preset: relativePreset.value }
|
||||
: query.value))
|
||||
function patch(values: LocationQueryRaw, push = false) {
|
||||
const next = { ...route.query, start_date: undefined, end_date: undefined, preset: undefined, tz_offset_minutes: undefined, ...rangeQuery(range.value), relative_preset: relativePreset.value || undefined, ...values }
|
||||
return router[push ? 'push' : 'replace']({ query: next })
|
||||
}
|
||||
function setRange(value: OverviewRange, preset?: string) {
|
||||
return patch({ ...rangeQuery(value), relative_preset: rolling && preset && relativePresets.includes(preset) ? preset : undefined, offset: undefined })
|
||||
}
|
||||
function link(path: string, filters: LocationQueryRaw = {}) {
|
||||
return { path, query: { ...rangeQuery(range.value), ...filters } }
|
||||
}
|
||||
return { route, router, range, query, relativePreset, scopeKey, refreshRange, patch, setRange, link }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { computed, onScopeDispose, ref } from 'vue'
|
||||
import { useOverviewI18n } from './i18n'
|
||||
|
||||
export function useOverviewAutoRefresh(refreshRange: () => void) {
|
||||
const { t } = useOverviewI18n()
|
||||
const revision = ref(0)
|
||||
const autoRefresh = ref(false)
|
||||
const refreshTitle = computed(() => autoRefresh.value
|
||||
? t('关闭自动刷新(每 10 秒)', 'Stop auto refresh (every 10 seconds)')
|
||||
: t('开启自动刷新(每 10 秒)', 'Start auto refresh (every 10 seconds)'))
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
function refreshAll() { refreshRange(); revision.value += 1 }
|
||||
function stop() {
|
||||
autoRefresh.value = false
|
||||
if (timer !== undefined) clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
function toggleAutoRefresh() {
|
||||
if (autoRefresh.value) { stop(); return }
|
||||
autoRefresh.value = true
|
||||
refreshAll()
|
||||
timer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') refreshAll()
|
||||
}, 10_000)
|
||||
}
|
||||
onScopeDispose(stop)
|
||||
return { revision, autoRefresh, refreshTitle, refreshAll, toggleAutoRefresh }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { onScopeDispose, ref } from 'vue'
|
||||
import { overviewApi, type OverviewQuery } from '@/api/overview'
|
||||
|
||||
export function useOverviewExport() {
|
||||
const exporting = ref(false)
|
||||
const exportError = ref<string | null>(null)
|
||||
let controller: AbortController | null = null
|
||||
async function exportCsv(path: 'users' | 'consumption' | 'breakdown', query: OverviewQuery) {
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
exporting.value = true
|
||||
exportError.value = null
|
||||
try {
|
||||
const blob = await overviewApi.exportCsv(path, query, controller.signal)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `aether-${path}-${query.from.slice(0, 10)}.csv`
|
||||
anchor.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted) return
|
||||
const response = cause as { response?: { data?: Blob }; message?: string }
|
||||
const body = response.response?.data
|
||||
if (body instanceof Blob) {
|
||||
try {
|
||||
const detail = JSON.parse(await body.text()) as { detail?: string; message?: string }
|
||||
exportError.value = detail.detail || detail.message || response.message || 'Export failed'
|
||||
} catch { exportError.value = response.message || 'Export failed' }
|
||||
} else exportError.value = response.message || 'Export failed'
|
||||
} finally { exporting.value = false }
|
||||
}
|
||||
onScopeDispose(() => controller?.abort())
|
||||
return { exporting, exportError, exportCsv }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { onScopeDispose, ref, shallowRef, toValue, watch, type WatchSource } from 'vue'
|
||||
|
||||
export function useOverviewRequest<T>(source: WatchSource, fetcher: (signal: AbortSignal) => Promise<T>, options: { scopeKey?: WatchSource<string> } = {}) {
|
||||
const data = shallowRef<T | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let controller: AbortController | null = null
|
||||
let generation = 0
|
||||
let previousScope: string | undefined
|
||||
async function refresh() {
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
const current = ++generation
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await fetcher(controller.signal)
|
||||
if (current === generation) data.value = result
|
||||
} catch (cause) {
|
||||
if (current !== generation || controller.signal.aborted) return
|
||||
const response = cause as { response?: { data?: { detail?: string; message?: string } }; message?: string }
|
||||
error.value = response.response?.data?.detail || response.response?.data?.message || response.message || 'Request failed'
|
||||
} finally {
|
||||
if (current === generation) loading.value = false
|
||||
}
|
||||
}
|
||||
watch(source, () => {
|
||||
const scope = options.scopeKey ? toValue(options.scopeKey) : undefined
|
||||
if (!options.scopeKey || scope !== previousScope) data.value = null
|
||||
previousScope = scope
|
||||
void refresh()
|
||||
}, { immediate: true })
|
||||
onScopeDispose(() => { generation += 1; controller?.abort() })
|
||||
return { data, loading, error, refresh }
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<section
|
||||
id="usage-records"
|
||||
class="min-w-0 scroll-mt-6 space-y-4 rounded-2xl border bg-card p-4 shadow-sm sm:p-5"
|
||||
data-user-consumption
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('使用记录', 'Usage records') }}
|
||||
</h2><Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="exporting"
|
||||
:title="t('导出完整筛选结果', 'Export all matching results')"
|
||||
:aria-label="t('导出完整筛选结果', 'Export all matching results')"
|
||||
@click="exportCsv('consumption', requestQuery)"
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<OverviewStatus
|
||||
:loading="loading && !data"
|
||||
:error="error || exportError"
|
||||
@retry="refresh"
|
||||
/>
|
||||
<template v-if="data">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[820px] text-sm">
|
||||
<thead class="border-y text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="whitespace-nowrap py-3 text-left font-medium">
|
||||
{{ t('请求 / 时间', 'Request / time') }}
|
||||
</th><th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('模型', 'Model') }}
|
||||
</th><th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('状态', 'Status') }}
|
||||
</th><th
|
||||
v-for="label in amountLabels"
|
||||
:key="label"
|
||||
class="whitespace-nowrap px-3 py-3 text-right font-medium"
|
||||
>
|
||||
{{ label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead><tbody>
|
||||
<tr
|
||||
v-for="item in data.data.items"
|
||||
:key="item.id"
|
||||
class="border-b last:border-0"
|
||||
>
|
||||
<td class="max-w-60 py-3">
|
||||
<RouterLink
|
||||
:to="link('/admin/usage', { user_id: userId, detail_id: item.id, request_id: item.request_id })"
|
||||
class="block truncate font-mono text-xs text-primary hover:underline"
|
||||
:title="item.request_id"
|
||||
>
|
||||
{{ item.request_id }}
|
||||
</RouterLink><p class="mt-1 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{{ timestamp(item.started_at, range.timezone) }}
|
||||
</p>
|
||||
</td><td class="max-w-52 break-words px-3 py-3">
|
||||
{{ item.model || '-' }}<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ item.provider || '-' }}
|
||||
</p>
|
||||
</td><td class="px-3 py-3">
|
||||
<span :class="item.status === 'failed' ? 'text-destructive' : ''">{{ statusLabel(item.status) }}</span><p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ statusLabel(item.settlement_status) }}
|
||||
</p>
|
||||
</td><td
|
||||
v-for="key in amounts"
|
||||
:key="key"
|
||||
class="whitespace-nowrap px-3 py-3 text-right tabular-nums"
|
||||
:title="item[key]?.value ?? t('未知', 'Unknown')"
|
||||
>
|
||||
{{ money(item[key]) }}<span
|
||||
v-if="item[key]?.status === 'known_subtotal'"
|
||||
class="block text-xs text-muted-foreground"
|
||||
>{{ t('已知小计', 'Known subtotal') }}</span>
|
||||
</td>
|
||||
</tr><tr v-if="!data.data.items.length">
|
||||
<td
|
||||
colspan="6"
|
||||
class="py-12 text-center text-muted-foreground"
|
||||
>
|
||||
{{ t('此时间范围暂无消费记录', 'No consumption in this range') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<OverviewPagination
|
||||
:total="data.data.total"
|
||||
:offset="requestQuery.offset"
|
||||
:limit="requestQuery.limit"
|
||||
:loading="loading"
|
||||
@page="offset => patch({ offset })"
|
||||
@size="limit => patch({ limit, offset: undefined })"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Download } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui'
|
||||
import { overviewApi } from '@/api/overview'
|
||||
import OverviewStatus from '../components/OverviewStatus.vue'
|
||||
import OverviewPagination from '../components/OverviewPagination.vue'
|
||||
import { useOverviewQuery } from '../query'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
import { useOverviewExport } from '../useOverviewExport'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { money, timestamp } from '../format'
|
||||
const props = defineProps<{ userId: string; revision: number }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { query, range, relativePreset, patch, link } = useOverviewQuery()
|
||||
const requestQuery = computed(() => ({ ...range.value, user_id: props.userId, limit: Math.max(1, Math.min(100, query.value.limit || 25)), offset: query.value.offset || 0, sort: 'started_at', order: 'desc' as const }))
|
||||
const scope = computed(() => JSON.stringify({ ...requestQuery.value, ...(relativePreset.value ? { from: undefined, to: undefined, preset: relativePreset.value } : {}) }))
|
||||
const { data, loading, error, refresh } = useOverviewRequest(() => JSON.stringify([requestQuery.value, props.revision]), signal => overviewApi.consumption(requestQuery.value, signal), { scopeKey: scope })
|
||||
const { exporting, exportError, exportCsv } = useOverviewExport()
|
||||
const amounts = ['billable_amount', 'quota_covered_amount', 'wallet_debit_amount'] as const
|
||||
const amountLabels = computed(() => [t('消费', 'Consumption'), t('额度抵扣', 'Quota covered'), t('余额扣款', 'Balance debited')])
|
||||
function statusLabel(status: string) {
|
||||
const labels: Record<string, string> = { success: t('成功', 'Successful'), completed: t('已完成', 'Completed'), failed: t('失败', 'Failed'), cancelled: t('已取消', 'Cancelled'), settled: t('已结算', 'Settled'), pending: t('待结算', 'Pending'), unknown: t('未知', 'Unknown') }
|
||||
return labels[status] || status || '-'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="true"
|
||||
:title="`${user.username} · ${t('账目', 'Account')}`"
|
||||
:description="t('金额与套餐历史 · 余额为当前值', 'Balance and plan history · Balances are current')"
|
||||
size="4xl"
|
||||
placement="right"
|
||||
@update:model-value="value => !value && $emit('close')"
|
||||
>
|
||||
<template #header-actions>
|
||||
<RefreshButton
|
||||
:loading="loading"
|
||||
:title="t('刷新', 'Refresh')"
|
||||
@click="refresh"
|
||||
/>
|
||||
<Button
|
||||
ref="closeButton"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="t('关闭账目', 'Close account')"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<div
|
||||
class="space-y-6"
|
||||
data-user-account-drawer
|
||||
>
|
||||
<section
|
||||
class="space-y-3"
|
||||
data-account-transactions
|
||||
>
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('金额变动', 'Balance changes') }}
|
||||
</h2>
|
||||
<OverviewStatus
|
||||
:error="walletError || transactionsError"
|
||||
@retry="refreshWallet"
|
||||
/>
|
||||
<dl
|
||||
v-if="wallet"
|
||||
class="grid grid-cols-1 gap-3 sm:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-for="item in balances"
|
||||
:key="item.label"
|
||||
class="rounded-xl border bg-muted/30 p-3"
|
||||
>
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</dt>
|
||||
<dd class="mt-1 break-all text-lg font-semibold tabular-nums">
|
||||
{{ item.value }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<template v-if="wallet || walletLoading || transactionsLoading">
|
||||
<WalletTransactionsTable
|
||||
v-if="!transactionsError || transactions"
|
||||
:items="transactions?.items || []"
|
||||
:loading="walletLoading || transactionsLoading"
|
||||
:timezone="timezone"
|
||||
/>
|
||||
<Pagination
|
||||
v-if="transactions"
|
||||
:total="transactions.total"
|
||||
:current="Math.floor(offset / limit) + 1"
|
||||
:page-size="limit"
|
||||
:page-size-options="[25, 50, 100]"
|
||||
:aria-busy="transactionsLoading"
|
||||
@update:current="changePage"
|
||||
@update:page-size="resize"
|
||||
/>
|
||||
</template>
|
||||
<p
|
||||
v-else-if="!walletError"
|
||||
class="py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('该用户尚无钱包', 'This user has no wallet yet') }}
|
||||
</p>
|
||||
</section>
|
||||
<section
|
||||
class="space-y-3"
|
||||
data-account-plans
|
||||
>
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('套餐变动', 'Plan history') }}
|
||||
</h2>
|
||||
<OverviewStatus
|
||||
:error="plansError"
|
||||
@retry="refreshPlans"
|
||||
/>
|
||||
<UserPlanList
|
||||
v-if="!plansError || plans"
|
||||
:entitlements="plans?.items || []"
|
||||
:loading="plansLoading"
|
||||
:timezone="timezone"
|
||||
show-updated-at
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ t('关闭', 'Close') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { Button, Dialog, Pagination, RefreshButton } from '@/components/ui'
|
||||
import { adminWalletApi } from '@/api/admin-wallets'
|
||||
import { usersApi } from '@/api/users'
|
||||
import WalletTransactionsTable from '@/features/wallet/components/WalletTransactionsTable.vue'
|
||||
import UserPlanList from '@/features/users/components/UserPlanList.vue'
|
||||
import OverviewStatus from '../components/OverviewStatus.vue'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { money } from '../format'
|
||||
|
||||
const props = defineProps<{ user: { user_id: string; username: string }; timezone: string }>()
|
||||
defineEmits<{ close: [] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const closeButton = ref<{ $el: HTMLButtonElement } | null>(null)
|
||||
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
onMounted(() => closeButton.value?.$el.focus({ preventScroll: true }))
|
||||
onUnmounted(() => {
|
||||
void nextTick(() => {
|
||||
if (previousFocus?.isConnected) previousFocus.focus({ preventScroll: true })
|
||||
})
|
||||
})
|
||||
const revision = ref(0)
|
||||
const limit = ref(25)
|
||||
const offset = ref(0)
|
||||
watch(() => props.user.user_id, () => { offset.value = 0 }, { flush: 'sync' })
|
||||
const {
|
||||
data: wallets, loading: walletLoading, error: walletError, refresh: reloadWallet,
|
||||
} = useOverviewRequest(
|
||||
() => JSON.stringify([props.user.user_id, revision.value]),
|
||||
signal => adminWalletApi.listWallets({ user_id: props.user.user_id, owner_type: 'user', limit: 1, offset: 0 }, signal),
|
||||
{ scopeKey: () => props.user.user_id },
|
||||
)
|
||||
const walletId = computed(() => wallets.value?.items.find(item => item.user_id === props.user.user_id)?.id)
|
||||
const {
|
||||
data: transactions, loading: transactionsLoading, error: transactionsError, refresh: reloadTransactions,
|
||||
} = useOverviewRequest(
|
||||
() => JSON.stringify([walletId.value, limit.value, offset.value, revision.value]),
|
||||
signal => walletId.value
|
||||
? adminWalletApi.getWalletTransactions(walletId.value, { limit: limit.value, offset: offset.value }, signal)
|
||||
: Promise.resolve(null),
|
||||
)
|
||||
const {
|
||||
data: plans, loading: plansLoading, error: plansError, refresh: refreshPlans,
|
||||
} = useOverviewRequest(
|
||||
() => JSON.stringify([props.user.user_id, revision.value]),
|
||||
signal => usersApi.listUserPlanEntitlements(props.user.user_id, { include_inactive: true }, signal),
|
||||
{ scopeKey: () => props.user.user_id },
|
||||
)
|
||||
const wallet = computed(() => transactions.value?.wallet || wallets.value?.items.find(item => item.user_id === props.user.user_id))
|
||||
const balances = computed(() => [
|
||||
{ label: t('余额', 'Balance'), value: walletMoney(wallet.value?.balance) },
|
||||
{ label: t('充值', 'Recharge balance'), value: walletMoney(wallet.value?.recharge_balance) },
|
||||
{ label: t('赠送', 'Gift balance'), value: walletMoney(wallet.value?.gift_balance) },
|
||||
])
|
||||
const loading = computed(() => walletLoading.value || transactionsLoading.value || plansLoading.value)
|
||||
function walletMoney(value?: number) {
|
||||
return money({ value: value == null ? null : String(value), currency: wallet.value?.currency || 'USD', basis: 'balance', status: value == null ? 'unknown' : 'known' })
|
||||
}
|
||||
function refresh() { revision.value += 1 }
|
||||
function refreshWallet() {
|
||||
if (walletId.value && !walletError.value) void reloadTransactions()
|
||||
else void reloadWallet()
|
||||
}
|
||||
function changePage(value: number) {
|
||||
if (!transactionsLoading.value) offset.value = (value - 1) * limit.value
|
||||
}
|
||||
function resize(value: number) {
|
||||
if (transactionsLoading.value) return
|
||||
limit.value = value
|
||||
offset.value = 0
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<section
|
||||
class="rounded-2xl border bg-card p-5 shadow-sm"
|
||||
data-user-summary="consumption"
|
||||
>
|
||||
<p class="text-xs font-medium text-muted-foreground">
|
||||
{{ t('消费', 'Consumption') }}
|
||||
</p>
|
||||
<p class="mt-3 break-all text-2xl font-semibold tabular-nums">
|
||||
{{ money(summary?.billable_amount) }}
|
||||
</p>
|
||||
<p class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground tabular-nums">
|
||||
<span class="whitespace-nowrap">{{ t('额度', 'Quota') }} {{ money(summary?.quota_covered_amount) }}</span>
|
||||
<span class="whitespace-nowrap">{{ t('余额', 'Balance') }} {{ money(summary?.wallet_debit_amount) }}</span>
|
||||
</p>
|
||||
</section>
|
||||
<section
|
||||
class="rounded-2xl border bg-card p-5 shadow-sm"
|
||||
data-user-summary="recharge"
|
||||
>
|
||||
<p class="text-xs font-medium text-muted-foreground">
|
||||
{{ t('充值', 'Recharges') }}
|
||||
</p>
|
||||
<p class="mt-3 break-all text-2xl font-semibold tabular-nums">
|
||||
{{ money(finance?.recharge_amount) }}
|
||||
</p>
|
||||
<p class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground tabular-nums">
|
||||
<span class="whitespace-nowrap">{{ t('套餐', 'Plans') }} {{ money(finance?.plan_purchase_amount) }}</span>
|
||||
<span class="whitespace-nowrap">{{ t('赠送', 'Gifts') }} {{ money(finance?.gift_credit_amount) }}</span>
|
||||
</p>
|
||||
</section>
|
||||
<section
|
||||
class="rounded-2xl border bg-card p-5 shadow-sm"
|
||||
data-user-summary="balance"
|
||||
>
|
||||
<p class="text-xs font-medium text-muted-foreground">
|
||||
{{ t('余额', 'Balance') }}
|
||||
</p>
|
||||
<p class="mt-3 break-all text-2xl font-semibold tabular-nums">
|
||||
{{ money(finance?.wallet_balance) }}
|
||||
</p>
|
||||
<p class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground tabular-nums">
|
||||
<span class="whitespace-nowrap">{{ t('充值', 'Recharges') }} {{ money(finance?.recharge_balance) }}</span>
|
||||
<span class="whitespace-nowrap">{{ t('赠送', 'Gifts') }} {{ money(finance?.gift_balance) }}</span>
|
||||
</p>
|
||||
</section>
|
||||
<section
|
||||
class="rounded-2xl border bg-card p-5 shadow-sm"
|
||||
data-user-summary="activity"
|
||||
>
|
||||
<p class="text-xs font-medium text-muted-foreground">
|
||||
{{ detail ? t('请求', 'Requests') : t('用户', 'Users') }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-3 text-2xl font-semibold tabular-nums"
|
||||
:title="detail ? undefined : t('活跃用户 / 总用户', 'Active users / Total users')"
|
||||
>
|
||||
{{ count(detail ? summary?.request_count : activeUserCount) }}<span
|
||||
v-if="!detail"
|
||||
class="ml-2 text-sm font-normal text-muted-foreground"
|
||||
>/ {{ count(userCount) }}</span>
|
||||
</p>
|
||||
<p class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground tabular-nums">
|
||||
<span class="whitespace-nowrap">{{ detail ? t('成功', 'Successful') : t('请求', 'Requests') }} {{ count(detail ? summary?.successful_request_count : summary?.request_count) }}</span>
|
||||
<span class="whitespace-nowrap">Tokens {{ count(summary?.total_tokens) }}</span>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OverviewMetrics, OverviewUserFinance } from '@/api/overview'
|
||||
import { count, money } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
defineProps<{ summary?: OverviewMetrics | null; finance?: OverviewUserFinance | null; userCount?: number; activeUserCount?: number; detail?: boolean }>()
|
||||
const { t } = useOverviewI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<section
|
||||
class="min-w-0 rounded-2xl border bg-card p-4 shadow-sm sm:p-5"
|
||||
data-user-payments
|
||||
>
|
||||
<div class="mb-4">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('充值与套餐记录', 'Recharge and plan records') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ t('按到账时间统计,赠送到账单独列示', 'Reported by credit time; gift credits are listed separately') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="overflow-x-auto"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<table class="w-full min-w-[650px] text-sm">
|
||||
<thead class="border-y text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="py-3 text-left font-medium">
|
||||
{{ t('订单', 'Order') }}
|
||||
</th><th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('类型', 'Type') }}
|
||||
</th><th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('支付方式', 'Payment method') }}
|
||||
</th><th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('到账金额', 'Credited amount') }}
|
||||
</th><th class="py-3 text-right font-medium">
|
||||
{{ t('到账时间', 'Credited at') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="payment in payments?.items || []"
|
||||
:key="payment.id"
|
||||
class="border-b last:border-0"
|
||||
>
|
||||
<td class="max-w-56 break-all py-3 font-mono text-xs">
|
||||
{{ payment.order_no || payment.id }}
|
||||
</td><td class="whitespace-nowrap px-3 py-3">
|
||||
{{ kindLabel(payment.kind) }}
|
||||
</td><td class="px-3 py-3 text-muted-foreground">
|
||||
{{ methodLabel(payment.payment_method) }}
|
||||
</td><td class="whitespace-nowrap px-3 py-3 text-right font-medium tabular-nums">
|
||||
{{ money(payment.amount) }}
|
||||
</td><td class="whitespace-nowrap py-3 text-right text-xs text-muted-foreground">
|
||||
{{ timestamp(payment.credited_at, timezone) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!payments?.items.length">
|
||||
<td
|
||||
colspan="5"
|
||||
class="py-12 text-center text-muted-foreground"
|
||||
>
|
||||
{{ loading ? t('加载中', 'Loading') : !payments ? t('到账记录暂不可用', 'Credit records unavailable') : t('此时间范围暂无到账记录', 'No credited payments in this period') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<OverviewPagination
|
||||
v-if="payments"
|
||||
:total="payments.total"
|
||||
:offset="offset"
|
||||
:limit="limit"
|
||||
:loading="loading"
|
||||
@page="value => $emit('page', value)"
|
||||
@size="value => $emit('size', value)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OverviewPage, OverviewUserPayment } from '@/api/overview'
|
||||
import OverviewPagination from '../components/OverviewPagination.vue'
|
||||
import { money, timestamp } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
defineProps<{ payments?: OverviewPage<OverviewUserPayment> | null; timezone: string; loading: boolean; limit: number; offset: number }>()
|
||||
defineEmits<{ page: [value: number]; size: [value: number] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
function kindLabel(kind: string) {
|
||||
return ({ wallet_recharge: t('钱包充值', 'Wallet recharge'), plan_purchase: t('套餐购买', 'Plan purchase'), gift_credit: t('赠送到账', 'Gift credit') })[kind] || kind
|
||||
}
|
||||
function methodLabel(method: string) {
|
||||
return ({ alipay: t('支付宝', 'Alipay'), wechat: t('微信支付', 'WeChat Pay'), stripe: 'Stripe', manual: t('人工入账', 'Manual credit'), gift_code: t('兑换码', 'Gift code') })[method] || method || '-'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<section
|
||||
class="space-y-4"
|
||||
data-user-reports
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('统计报表', 'Reports') }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="!userId"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('全站用量 · 包含独立余额 Key · 不受用户搜索影响', 'Installation usage · Includes standalone balance keys · Independent of user search') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="exporting"
|
||||
@click="exportCsv('breakdown', modelQuery)"
|
||||
>
|
||||
<Download class="mr-2 h-4 w-4" />{{ t('导出模型报表', 'Export model report') }}
|
||||
</Button>
|
||||
</div>
|
||||
<OverviewStatus :error="exportError" />
|
||||
<div class="grid min-w-0 gap-4 xl:grid-cols-2">
|
||||
<section
|
||||
class="min-w-0 rounded-2xl border bg-card p-5 shadow-sm"
|
||||
data-user-report="consumption"
|
||||
>
|
||||
<h3 class="text-sm font-medium">
|
||||
{{ t('消费趋势', 'Consumption trend') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ t('计费消费 · USD', 'Billable consumption · USD') }}
|
||||
</p>
|
||||
<OverviewStatus
|
||||
:error="seriesError"
|
||||
@retry="refreshSeries"
|
||||
/>
|
||||
<div
|
||||
v-if="series?.data.items.length"
|
||||
class="mt-5 h-64 min-w-0"
|
||||
>
|
||||
<LineChart :data="consumptionChart" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-72 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ seriesLoading ? t('加载中', 'Loading') : seriesError ? t('消费报表暂不可用', 'Consumption report unavailable') : t('此时间范围暂无消费', 'No consumption in this period') }}
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="min-w-0 rounded-2xl border bg-card p-5 shadow-sm"
|
||||
data-user-report="models"
|
||||
>
|
||||
<h3 class="text-sm font-medium">
|
||||
{{ t('模型用量', 'Model usage') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ t('按 Token 用量排列 · 前 10 个模型', 'By token usage · Top 10 models') }}
|
||||
</p>
|
||||
<OverviewStatus
|
||||
:error="modelsError"
|
||||
@retry="refreshModels"
|
||||
/>
|
||||
<div
|
||||
v-if="models?.data.items.length"
|
||||
class="mt-5 h-64 min-w-0"
|
||||
>
|
||||
<BarChart
|
||||
:data="modelChart"
|
||||
:stacked="false"
|
||||
:options="modelOptions"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-72 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ modelsLoading ? t('加载中', 'Loading') : modelsError ? t('模型报表暂不可用', 'Model report unavailable') : t('此时间范围暂无模型使用', 'No model usage in this period') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="models?.data.items.length"
|
||||
class="mt-4 max-h-64 overflow-auto"
|
||||
>
|
||||
<table class="w-full min-w-[420px] text-xs">
|
||||
<thead class="border-b text-muted-foreground">
|
||||
<tr>
|
||||
<th class="py-2 text-left font-normal">
|
||||
{{ t('模型', 'Model') }}
|
||||
</th><th class="px-2 py-2 text-right font-normal">
|
||||
{{ t('请求数', 'Requests') }}
|
||||
</th><th class="px-2 py-2 text-right font-normal">
|
||||
Tokens
|
||||
</th><th class="py-2 text-right font-normal">
|
||||
{{ t('消费', 'Consumption') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in models.data.items"
|
||||
:key="item.id ?? '__unknown__'"
|
||||
class="border-b last:border-0"
|
||||
>
|
||||
<td class="max-w-40 break-words py-2">
|
||||
{{ item.label || item.id || t('未知模型', 'Unknown model') }}
|
||||
</td><td class="px-2 py-2 text-right tabular-nums">
|
||||
{{ count(item.request_count) }}
|
||||
</td><td class="px-2 py-2 text-right tabular-nums">
|
||||
{{ count(item.total_tokens) }}
|
||||
</td><td class="whitespace-nowrap py-2 text-right tabular-nums">
|
||||
{{ money(item.billable_amount) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ChartOptions } from 'chart.js'
|
||||
import { Download } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui'
|
||||
import { overviewApi } from '@/api/overview'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
import BarChart from '@/components/charts/BarChart.vue'
|
||||
import OverviewStatus from '../components/OverviewStatus.vue'
|
||||
import { useOverviewQuery } from '../query'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
import { useOverviewExport } from '../useOverviewExport'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { count, money, timestamp } from '../format'
|
||||
const props = defineProps<{ userId?: string; revision: number }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { range, relativePreset } = useOverviewQuery()
|
||||
const requestQuery = computed(() => ({ ...range.value, user_id: props.userId, granularity: Date.parse(range.value.to) - Date.parse(range.value.from) <= 48 * 3_600_000 ? 'hour' as const : 'day' as const }))
|
||||
const modelQuery = computed(() => ({ ...requestQuery.value, group_by: 'model', sort: 'total_tokens', order: 'desc' as const, limit: 10, offset: 0 }))
|
||||
const scope = computed(() => JSON.stringify({ user: props.userId, ...(relativePreset.value ? { preset: relativePreset.value, timezone: range.value.timezone } : range.value) }))
|
||||
const { data: series, loading: seriesLoading, error: seriesError, refresh: refreshSeries } = useOverviewRequest(() => JSON.stringify([requestQuery.value, props.revision]), signal => overviewApi.timeseries(requestQuery.value, signal), { scopeKey: scope })
|
||||
const { data: models, loading: modelsLoading, error: modelsError, refresh: refreshModels } = useOverviewRequest(() => JSON.stringify([modelQuery.value, props.revision]), signal => overviewApi.breakdown(modelQuery.value, signal), { scopeKey: scope })
|
||||
const { exporting, exportError, exportCsv } = useOverviewExport()
|
||||
const consumptionChart = computed(() => ({
|
||||
labels: (series.value?.data.items || []).map(item => timestamp(item.bucket_start, range.value.timezone)),
|
||||
datasets: [{ label: t('消费 (USD)', 'Consumption (USD)'), data: (series.value?.data.items || []).map(item => {
|
||||
const value = item.billable_amount?.value
|
||||
return value == null || !Number.isFinite(Number(value)) ? null : Number(value)
|
||||
}), borderColor: '#cb694b', backgroundColor: '#cb694b', pointRadius: 2, borderWidth: 2, tension: 0.15 }],
|
||||
}))
|
||||
const modelChart = computed(() => ({ labels: (models.value?.data.items || []).map(item => item.label || item.id || t('未知模型', 'Unknown model')), datasets: [{ label: 'Tokens', data: (models.value?.data.items || []).map(item => item.total_tokens), backgroundColor: '#cb694b', borderRadius: 4, maxBarThickness: 24 }] }))
|
||||
const modelOptions: ChartOptions<'bar'> = { indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { beginAtZero: true }, y: { grid: { display: false } } } }
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export { useOverviewAutoRefresh as useUserAnalysisRefresh } from '../useOverviewAutoRefresh'
|
||||
@@ -1,12 +1,16 @@
|
||||
<template>
|
||||
<Card class="p-4 overflow-hidden">
|
||||
<Card
|
||||
class="p-4 overflow-hidden"
|
||||
:title="tooltip"
|
||||
>
|
||||
<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"
|
||||
class="items-center gap-1 text-[11px] text-muted-foreground flex-shrink-0"
|
||||
:class="compact ? 'hidden sm:flex' : 'flex'"
|
||||
>
|
||||
<span class="flex-shrink-0">少</span>
|
||||
<div
|
||||
@@ -36,6 +40,7 @@
|
||||
v-else-if="hasData"
|
||||
:data="data"
|
||||
:show-header="false"
|
||||
:compact="compact"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
@@ -56,6 +61,8 @@ import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
|
||||
const props = defineProps<{
|
||||
data: ActivityHeatmapData | null
|
||||
title: string
|
||||
tooltip?: string
|
||||
compact?: boolean
|
||||
isLoading?: boolean
|
||||
hasError?: boolean
|
||||
}>()
|
||||
|
||||
@@ -68,9 +68,11 @@ const props = withDefaults(defineProps<{
|
||||
isAdmin: boolean
|
||||
hours?: number
|
||||
refreshIntervalMs?: number
|
||||
data?: IntervalTimelineResponse | null
|
||||
}>(), {
|
||||
hours: 24, // 默认当天
|
||||
refreshIntervalMs: 30000
|
||||
refreshIntervalMs: 30000,
|
||||
data: undefined,
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -292,6 +294,11 @@ const chartOptions = computed<ChartOptions<'scatter'>>(() => ({
|
||||
|
||||
async function loadData() {
|
||||
const requestId = ++loadRequestId
|
||||
if (props.data !== undefined) {
|
||||
timelineData.value = props.data
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const limit = props.isAdmin ? ADMIN_TIMELINE_LIMIT : USER_TIMELINE_LIMIT
|
||||
@@ -332,6 +339,7 @@ function stopRefreshTimer() {
|
||||
}
|
||||
|
||||
function scheduleNextRefresh() {
|
||||
if (props.data !== undefined) return
|
||||
if (refreshTimer) return
|
||||
if (!isPageVisible.value) return
|
||||
if (!props.refreshIntervalMs || props.refreshIntervalMs <= 0) return
|
||||
@@ -355,7 +363,7 @@ function handleVisibilityChange() {
|
||||
scheduleNextRefresh()
|
||||
}
|
||||
|
||||
watch([() => props.hours, () => props.isAdmin], () => {
|
||||
watch([() => props.hours, () => props.isAdmin, () => props.data], () => {
|
||||
void loadData()
|
||||
stopRefreshTimer()
|
||||
scheduleNextRefresh()
|
||||
|
||||
@@ -137,6 +137,18 @@ describe('useUsageData', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('preserves precise overview scope and false performance filters in records and total requests', async () => {
|
||||
const { loadRecords } = useUsageData({ isAdminPage: ref(true) })
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({ records: [], total: 100, total_is_estimated: true })
|
||||
const range = { from: '2026-09-10T16:20:42.000Z', to: '2026-09-10T17:20:42.000Z', timezone: 'Asia/Shanghai' }
|
||||
const filters = { provider_id: 'provider-1', api_key_id: 'key-1', endpoint_kind: 'chat', request_type: 'standard', is_stream: false, has_format_conversion: false, slow_threshold_ms: 8000 }
|
||||
await loadRecords({ page: 2, pageSize: 20 }, filters, range)
|
||||
await flushMicrotasks()
|
||||
expect(getAllUsageRecordsMock).toHaveBeenCalledWith(expect.objectContaining({ ...range, ...filters, offset: 20 }))
|
||||
expect(getAllUsageRecordTotalMock).toHaveBeenCalledWith(expect.objectContaining({ ...range, ...filters }))
|
||||
expect(getAllUsageRecordsMock.mock.calls[0][0].preset).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sends the WebSocket type filter to the user records endpoint before pagination', async () => {
|
||||
const isAdminPage = ref(false)
|
||||
const { loadRecords } = useUsageData({ isAdminPage })
|
||||
|
||||
@@ -36,6 +36,15 @@ export interface PaginationParams {
|
||||
}
|
||||
|
||||
export interface FilterParams {
|
||||
provider_id?: string
|
||||
api_key_id?: string
|
||||
request_id?: string
|
||||
attribution_kind?: string
|
||||
endpoint_kind?: string
|
||||
request_type?: string
|
||||
is_stream?: boolean
|
||||
has_format_conversion?: boolean
|
||||
slow_threshold_ms?: number
|
||||
search?: string
|
||||
user_id?: string
|
||||
model?: string
|
||||
@@ -399,6 +408,12 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
|
||||
if (isAdminPage.value) {
|
||||
// 管理员页面:使用管理员 API
|
||||
for (const key of ['provider_id', 'api_key_id', 'request_id', 'attribution_kind', 'endpoint_kind', 'request_type'] as const) {
|
||||
if (filters?.[key]) params[key] = filters[key]
|
||||
}
|
||||
for (const key of ['is_stream', 'has_format_conversion', 'slow_threshold_ms'] as const) {
|
||||
if (filters?.[key] !== undefined) params[key] = filters[key]
|
||||
}
|
||||
if (filters?.user_id) {
|
||||
params.user_id = filters.user_id
|
||||
}
|
||||
|
||||
@@ -79,6 +79,8 @@ export interface ApiFormatStatsItem {
|
||||
// 请求状态类型
|
||||
// 日期范围参数
|
||||
export interface DateRangeParams {
|
||||
from?: string
|
||||
to?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
|
||||
@@ -43,70 +43,26 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loadingEntitlements"
|
||||
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
<UserPlanList
|
||||
:entitlements="entitlements"
|
||||
:loading="loadingEntitlements"
|
||||
:format-date-time="formatDateTime"
|
||||
:entitlement-labels="entitlementLabels"
|
||||
:empty-text="legacyT('当前没有有效套餐')"
|
||||
>
|
||||
{{ legacyT('正在加载用户套餐...') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="entitlements.length === 0"
|
||||
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ legacyT('当前没有有效套餐') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="space-y-2.5"
|
||||
>
|
||||
<div
|
||||
v-for="item in entitlements"
|
||||
:key="item.id"
|
||||
class="rounded-lg border border-border bg-card/80 p-3"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-foreground">
|
||||
{{ item.plan_title || item.plan?.title || item.plan_id }}
|
||||
</span>
|
||||
<Badge
|
||||
:variant="item.active ? 'success' : 'secondary'"
|
||||
class="h-5 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{{ item.active ? legacyT('生效中') : item.status }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
v-for="(label, index) in entitlementLabels(item.entitlements)"
|
||||
:key="`${label}-${index}`"
|
||||
variant="outline"
|
||||
class="h-5 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{{ label }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-start gap-2">
|
||||
<div class="text-left text-[11px] text-muted-foreground sm:text-right">
|
||||
<div>{{ legacyT('开始:') }}{{ formatDateTime(item.starts_at) }}</div>
|
||||
<div>{{ legacyT('到期:') }}{{ formatDateTime(item.expires_at) }}</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="item.active"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[11px]"
|
||||
:disabled="revokingEntitlementId === item.id"
|
||||
@click="$emit('revoke', item)"
|
||||
>
|
||||
{{ revokingEntitlementId === item.id ? legacyT('撤销中...') : legacyT('撤销套餐') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #actions="{ item }">
|
||||
<Button
|
||||
v-if="item.active"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[11px]"
|
||||
:disabled="revokingEntitlementId === item.id"
|
||||
@click="$emit('revoke', item)"
|
||||
>
|
||||
{{ revokingEntitlementId === item.id ? legacyT('撤销中...') : legacyT('撤销套餐') }}
|
||||
</Button>
|
||||
</template>
|
||||
</UserPlanList>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 rounded-lg border border-border bg-card/70 p-4">
|
||||
@@ -185,8 +141,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PackageCheck } from 'lucide-vue-next'
|
||||
import UserPlanList from './UserPlanList.vue'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
Select,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
{{ legacyT('正在加载用户套餐...') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!entitlements.length"
|
||||
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ emptyText || text('暂无套餐记录', 'No plan records') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="space-y-2.5"
|
||||
>
|
||||
<div
|
||||
v-for="item in entitlements"
|
||||
:key="item.id"
|
||||
class="rounded-lg border border-border bg-card/80 p-3"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="break-words font-medium text-foreground">{{ item.plan_title || item.plan?.title || item.plan_id }}</span>
|
||||
<Badge
|
||||
:variant="item.active ? 'success' : 'secondary'"
|
||||
class="h-5 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{{ statusLabel(item) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
v-for="(label, index) in labels(item.entitlements)"
|
||||
:key="`${label}-${index}`"
|
||||
variant="outline"
|
||||
class="h-5 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{{ label }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-start gap-2">
|
||||
<div class="text-left text-[11px] text-muted-foreground sm:text-right">
|
||||
<div>{{ legacyT('开始:') }}{{ dateTime(item.starts_at) }}</div>
|
||||
<div>{{ legacyT('到期:') }}{{ dateTime(item.expires_at) }}</div>
|
||||
<div v-if="showUpdatedAt">
|
||||
{{ text('更新:', 'Updated: ') }}{{ dateTime(item.updated_at || item.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<slot
|
||||
name="actions"
|
||||
:item="item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Badge } from '@/components/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { AdminUserPlanEntitlement } from '@/api/users'
|
||||
import type { BillingEntitlement } from '@/api/billing'
|
||||
import { billingEntitlementLabels } from '@/utils/billingEntitlements'
|
||||
|
||||
const props = defineProps<{
|
||||
entitlements: AdminUserPlanEntitlement[]
|
||||
loading?: boolean
|
||||
emptyText?: string
|
||||
showUpdatedAt?: boolean
|
||||
timezone?: string
|
||||
formatDateTime?: (value?: string | null) => string
|
||||
entitlementLabels?: (items: BillingEntitlement[] | undefined) => string[]
|
||||
}>()
|
||||
const { locale, legacyT } = useI18n()
|
||||
const text = (zh: string, en: string) => locale.value === 'zh-CN' ? zh : en
|
||||
const labels = (items: BillingEntitlement[]) => props.entitlementLabels?.(items) ?? billingEntitlementLabels(items, legacyT)
|
||||
function dateTime(value?: string | null) {
|
||||
if (props.formatDateTime) return props.formatDateTime(value)
|
||||
return value ? new Date(value).toLocaleString(locale.value, {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: props.timezone,
|
||||
}) : '-'
|
||||
}
|
||||
function statusLabel(item: AdminUserPlanEntitlement) {
|
||||
if (item.active) return text('生效中', 'Active')
|
||||
if (item.status === 'revoked') return text('已撤销', 'Revoked')
|
||||
if (item.status === 'replaced') return text('已替换', 'Replaced')
|
||||
if (item.status === 'expired' || (item.status === 'active' && item.expires_at && Date.parse(item.expires_at) <= Date.now())) return text('已到期', 'Expired')
|
||||
if (item.status === 'active' && item.starts_at && Date.parse(item.starts_at) > Date.now()) return text('待生效', 'Scheduled')
|
||||
return item.status
|
||||
}
|
||||
</script>
|
||||
@@ -264,74 +264,10 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-border/60 overflow-hidden bg-background">
|
||||
<div class="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>余额变化</TableHead>
|
||||
<TableHead>说明</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="tx in txItems"
|
||||
:key="tx.id"
|
||||
>
|
||||
<TableCell class="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{{ formatDateTime(tx.created_at) }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="space-y-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="font-mono"
|
||||
>
|
||||
{{ walletTransactionCategoryLabel(tx.category) }}
|
||||
</Badge>
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
{{ walletTransactionReasonLabel(tx.reason_code) }}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="tabular-nums"
|
||||
:class="toFiniteNumber(tx.amount) >= 0 ? 'text-emerald-600' : 'text-rose-600'"
|
||||
>
|
||||
{{ toFiniteNumber(tx.amount) >= 0 ? '+' : '' }}{{ formatFixed(tx.amount, 4) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-xs tabular-nums whitespace-nowrap">
|
||||
<div>{{ formatFixed(tx.balance_before, 4) }} → {{ formatFixed(tx.balance_after, 4) }}</div>
|
||||
<div
|
||||
v-if="tx.recharge_balance_before !== null && tx.recharge_balance_before !== undefined && tx.gift_balance_before !== null && tx.gift_balance_before !== undefined"
|
||||
class="text-[11px] text-muted-foreground mt-0.5"
|
||||
>
|
||||
充 {{ formatFixed(tx.recharge_balance_before, 4) }}→{{ formatFixed(tx.recharge_balance_after, 4) }}
|
||||
· 赠 {{ formatFixed(tx.gift_balance_before, 4) }}→{{ formatFixed(tx.gift_balance_after, 4) }}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-xs text-muted-foreground max-w-[260px] truncate">
|
||||
{{ tx.description || '-' }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="!loadingTx && txItems.length === 0">
|
||||
<TableCell
|
||||
colspan="5"
|
||||
class="py-10"
|
||||
>
|
||||
<EmptyState
|
||||
title="暂无资金流水"
|
||||
description="当前钱包没有资金动作记录"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<WalletTransactionsTable
|
||||
:items="txItems"
|
||||
:loading="loadingTx"
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
:current="txPage"
|
||||
@@ -509,7 +445,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getI18nLocale } from '@/i18n'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
Badge,
|
||||
@@ -535,6 +470,7 @@ import {
|
||||
TabsTrigger,
|
||||
} from '@/components/ui'
|
||||
import { EmptyState } from '@/components/common'
|
||||
import WalletTransactionsTable from './WalletTransactionsTable.vue'
|
||||
import {
|
||||
adminWalletApi,
|
||||
type AdminWallet,
|
||||
@@ -548,8 +484,6 @@ import {
|
||||
refundStatusLabel,
|
||||
walletStatusBadge,
|
||||
walletStatusLabel,
|
||||
walletTransactionCategoryLabel,
|
||||
walletTransactionReasonLabel,
|
||||
} from '@/utils/walletDisplay'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
@@ -1026,17 +960,6 @@ async function submitCompleteRefund() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString(getI18nLocale(), {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown, fallback = 0): number {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="rounded-2xl border border-border/60 overflow-hidden bg-background">
|
||||
<div
|
||||
class="overflow-x-auto"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>余额变化</TableHead>
|
||||
<TableHead>说明</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="loading && !items.length">
|
||||
<TableCell
|
||||
colspan="5"
|
||||
class="py-10 text-center text-muted-foreground"
|
||||
>
|
||||
{{ legacyT('加载中...') }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-for="tx in items"
|
||||
:key="tx.id"
|
||||
>
|
||||
<TableCell class="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{{ formatDateTime(tx.created_at) }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="space-y-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="font-mono"
|
||||
>
|
||||
{{ walletTransactionCategoryLabel(tx.category) }}
|
||||
</Badge>
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
{{ walletTransactionReasonLabel(tx.reason_code) }}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="tabular-nums"
|
||||
:class="toFiniteNumber(tx.amount) >= 0 ? 'text-emerald-600' : 'text-rose-600'"
|
||||
>
|
||||
{{ toFiniteNumber(tx.amount) >= 0 ? '+' : '' }}{{ formatFixed(tx.amount, 4) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-xs tabular-nums whitespace-nowrap">
|
||||
<div>{{ formatFixed(tx.balance_before, 4) }} → {{ formatFixed(tx.balance_after, 4) }}</div>
|
||||
<div
|
||||
v-if="tx.recharge_balance_before !== null && tx.recharge_balance_before !== undefined && tx.gift_balance_before !== null && tx.gift_balance_before !== undefined"
|
||||
class="text-[11px] text-muted-foreground mt-0.5"
|
||||
>
|
||||
充 {{ formatFixed(tx.recharge_balance_before, 4) }}→{{ formatFixed(tx.recharge_balance_after, 4) }}
|
||||
· 赠 {{ formatFixed(tx.gift_balance_before, 4) }}→{{ formatFixed(tx.gift_balance_after, 4) }}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-xs text-muted-foreground max-w-[260px] truncate">
|
||||
{{ tx.description || '-' }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="!loading && items.length === 0">
|
||||
<TableCell
|
||||
colspan="5"
|
||||
class="py-10"
|
||||
>
|
||||
<EmptyState
|
||||
title="暂无资金流水"
|
||||
description="当前钱包没有资金动作记录"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n, getI18nLocale } from '@/i18n'
|
||||
import { Badge, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
|
||||
import { EmptyState } from '@/components/common'
|
||||
import type { WalletTransaction } from '@/api/wallet'
|
||||
import { walletTransactionCategoryLabel, walletTransactionReasonLabel } from '@/utils/walletDisplay'
|
||||
|
||||
const props = defineProps<{ items: WalletTransaction[]; loading?: boolean; timezone?: string }>()
|
||||
const { legacyT } = useI18n()
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString(getI18nLocale(), {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
timeZone: props.timezone,
|
||||
})
|
||||
}
|
||||
function toFiniteNumber(value: unknown) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
function formatFixed(value: unknown, digits: number) {
|
||||
return toFiniteNumber(value).toFixed(digits)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user