feat: unify user analytics and optimize overview aggregation

Merge user accounts and usage reporting into one page with a combined ranking and account table, shared precise time ranges, and simpler range labels.

Parse overview metadata once through a schema-only view migration and disable JIT locally for bucket rebuilds. Preserve automatic backfills.

Add redacted OAuth refresh diagnostics, bucket failure context, and regression coverage. Resolve strict Clippy warnings.
This commit is contained in:
elky
2026-10-05 00:28:31 +08:00
parent 2075cd95de
commit cb7b9c9ecd
27 changed files with 1255 additions and 587 deletions
@@ -6,6 +6,7 @@
:preset="relativePreset"
:refresh-active="autoRefresh"
:refresh-title="refreshTitle"
:show-range="false"
presets-only
@update:range="setRange"
@refresh="toggleAutoRefresh"
@@ -6,6 +6,7 @@
:preset="relativePreset"
:refresh-active="autoRefresh"
:refresh-title="refreshTitle"
:show-range="false"
presets-only
:loading="loading"
@update:range="setRange"
+248 -207
View File
@@ -1,235 +1,253 @@
<template>
<main class="space-y-5 px-4 pb-8 sm:px-6 lg:px-0">
<Tabs v-model="activeTab">
<TabsList class="mb-4">
<TabsTrigger value="accounts">
{{ t('用户账目', 'User accounts') }}
</TabsTrigger>
<TabsTrigger value="usage">
{{ t('使用统计', 'Usage statistics') }}
</TabsTrigger>
</TabsList>
<TabsContent
value="accounts"
class="space-y-5"
<OverviewToolbar
:title="t('用户分析', 'User analysis')"
:range="range"
:show-range="false"
:refresh-active="autoRefresh"
:refresh-title="refreshTitle"
@update:range="setRange"
@refresh="toggleAutoRefresh"
>
<template #range-picker>
<TimeRangePicker
:model-value="{ ...range, preset: relativePreset || undefined }"
:preset-options="['last1hour', 'today', 'yesterday', 'last24hours', 'last7days', 'last30days', 'last90days', 'custom']"
:show-granularity="false"
@update:model-value="handleRangePicker"
/>
</template>
<Button
variant="outline"
size="sm"
:disabled="exporting"
@click="exportCsv('users', requestQuery)"
>
<OverviewToolbar
:title="t('用户分析', 'User analysis')"
<Download class="mr-2 h-4 w-4" />{{ t('导出用户报表', 'Export user report') }}
</Button>
</OverviewToolbar>
<OverviewStatus
:error="error || exportError"
@retry="refreshAll"
/>
<UserFinanceSummary
:summary="data?.data.summary"
:finance="data?.data.finance_summary"
:user-count="data?.data.summary?.user_count"
:active-user-count="data?.data.summary?.active_user_count"
/>
<UserReports :revision="revision">
<template #additional>
<UserUsageStats
:range="range"
:preset="relativePreset"
:show-range="false"
:refresh-active="autoRefresh"
:refresh-title="refreshTitle"
presets-only
@update:range="setRange"
@refresh="toggleAutoRefresh"
:revision="revision"
>
<Button
variant="outline"
size="sm"
:disabled="exporting"
@click="exportCsv('users', requestQuery)"
>
<Download class="mr-2 h-4 w-4" />{{ t('导出用户报表', 'Export user report') }}
</Button>
</OverviewToolbar>
<OverviewStatus
:error="error || exportError"
@retry="refreshAll"
/>
<UserFinanceSummary
:summary="data?.data.summary"
:finance="data?.data.finance_summary"
:user-count="data?.data.summary?.user_count"
:active-user-count="data?.data.summary?.active_user_count"
/>
<UserReports :revision="revision" />
<TableCard
:title="t('用户账目', 'User accounts')"
:description="t('消费与到账按所选时间统计,余额为当前值', 'Consumption and credits follow the selected period; balances are current')"
class="relative min-w-0"
data-user-accounts
>
<template #actions>
<form
class="flex w-full items-center gap-2 md:w-auto"
@submit.prevent="patch({ search: search.trim() || undefined, offset: undefined })"
<template #user-leaderboard="{ selectUser }">
<TableCard
:title="t('用户排行与账目', 'User rankings and accounts')"
:description="t('点击消费、请求数或 Tokens 排序;消费与到账按所选时间统计,余额为当前值', 'Sort by consumption, requests or Tokens; consumption and credits follow the selected period, balances are current')"
class="relative min-w-0"
data-user-accounts
>
<div class="relative min-w-0 flex-1 md:w-48 md:flex-none">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="search"
type="search"
:placeholder="t('搜索用户', 'Search users')"
:aria-label="t('搜索用户', 'Search users')"
class="h-8 w-full text-xs border-border/60 pl-8"
/>
</div>
<Button
type="submit"
size="sm"
variant="outline"
class="h-8 text-xs"
>
{{ t('查询', 'Search') }}
</Button>
</form>
</template>
<p
v-if="query.search"
class="border-b border-border/60 px-4 py-3 text-xs text-muted-foreground sm:px-6"
>
{{ t('上方汇总与账目仅包含匹配的用户', 'The summary and accounts include matching users only') }}
</p>
<div
class="min-w-0"
:aria-busy="loading"
>
<Table class="min-w-[1040px]">
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 font-semibold">
{{ t('用户', 'User') }}
</TableHead>
<SortableTableHead
v-for="column in columns"
:key="column.key"
class="h-12 font-semibold text-right"
:column-key="column.key"
:active-key="requestQuery.sort"
:direction="requestQuery.order"
default-direction="desc"
align="right"
@sort="sort"
>
{{ column.label }}
</SortableTableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('充值', 'Recharges') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('余额', 'Balance') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('最近使用', 'Last used') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('记录', 'Records') }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="user in data?.data.items || []"
:key="user.user_id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]"
<template #actions>
<form
class="flex w-full items-center gap-2 md:w-auto"
@submit.prevent="patch({ search: search.trim() || undefined, offset: undefined })"
>
<TableCell class="max-w-64 py-4 text-xs">
<button
type="button"
class="break-words hover:text-primary hover:underline"
@click="accountUser = user"
>
{{ user.username }}
</button>
<span
v-if="!user.is_active"
class="ml-2 text-xs text-muted-foreground"
>{{ t('停用', 'Disabled') }}</span>
<p class="mt-0.5 break-words text-xs text-muted-foreground">
{{ user.email }}
</p>
</TableCell>
<TableCell
class="whitespace-nowrap py-4 text-right text-xs font-medium tabular-nums"
:title="user.billable_amount?.value ?? ''"
<div class="relative min-w-0 flex-1 md:w-48 md:flex-none">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="search"
type="search"
:placeholder="t('搜索用户', 'Search users')"
:aria-label="t('搜索用户', 'Search users')"
class="h-8 w-full text-xs border-border/60 pl-8"
/>
</div>
<Button
type="submit"
size="sm"
variant="outline"
class="h-8 text-xs"
>
{{ money(user.billable_amount) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.request_count) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.total_tokens) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.recharge_amount) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.wallet_balance) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs text-muted-foreground">
{{ timestamp(user.last_used_at, range.timezone) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs">
<Button
type="button"
variant="ghost"
size="icon"
:title="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
:aria-label="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
@click="accountUser = user"
{{ t('查询', 'Search') }}
</Button>
</form>
</template>
<p
v-if="query.search"
class="border-b border-border/60 px-4 py-3 text-xs text-muted-foreground sm:px-6"
>
{{ t('上方汇总与账目仅包含匹配的用户', 'The summary and accounts include matching users only') }}
</p>
<div
class="min-w-0"
:aria-busy="loading"
>
<Table class="min-w-[1120px]">
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 w-16 font-semibold">
{{ t('序号', 'No.') }}
</TableHead>
<TableHead class="h-12 font-semibold">
{{ t('用户', 'User') }}
</TableHead>
<SortableTableHead
v-for="column in columns"
:key="column.key"
class="h-12 font-semibold text-right"
:column-key="column.key"
:active-key="requestQuery.sort"
:direction="requestQuery.order"
default-direction="desc"
align="right"
@sort="sort"
>
{{ column.label }}
</SortableTableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('充值', 'Recharges') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('余额', 'Balance') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('最近使用', 'Last used') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('操作', 'Actions') }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="(user, index) in data?.data.items || []"
:key="user.user_id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]"
>
<ReceiptText class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
<TableRow v-if="!data?.data.items.length">
<TableCell
colspan="8"
class="py-12 text-center text-muted-foreground"
>
{{ loading ? t('加载中', 'Loading') : error ? t('用户账目暂不可用', 'User accounts unavailable') : t('没有符合条件的用户', 'No matching users') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<template #pagination>
<Pagination
v-if="data"
:total="data.data.total"
:current="Math.floor(requestQuery.offset / requestQuery.limit) + 1"
:page-size="requestQuery.limit"
:page-size-options="[25, 50, 100]"
@update:current="changePage"
@update:page-size="changePageSize"
/>
<TableCell
class="py-4 text-xs font-medium tabular-nums"
data-user-rank
>
{{ (data?.data.offset ?? requestQuery.offset) + index + 1 }}
</TableCell>
<TableCell class="max-w-64 py-4 text-xs">
<button
type="button"
class="break-words hover:text-primary hover:underline"
@click="accountUser = user"
>
{{ user.username }}
</button>
<span
v-if="!user.is_active"
class="ml-2 text-xs text-muted-foreground"
>{{ t('停用', 'Disabled') }}</span>
<p class="mt-0.5 break-words text-xs text-muted-foreground">
{{ user.email }}
</p>
</TableCell>
<TableCell
class="whitespace-nowrap py-4 text-right text-xs font-medium tabular-nums"
:title="user.billable_amount?.value ?? ''"
>
{{ money(user.billable_amount) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.request_count) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.total_tokens) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.recharge_amount) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.wallet_balance) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs text-muted-foreground">
{{ timestamp(user.last_used_at, range.timezone) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs">
<Button
type="button"
variant="ghost"
size="icon"
:title="t('查看', 'View') + ' ' + user.username + ' ' + t('使用趋势', 'usage trend')"
:aria-label="t('查看', 'View') + ' ' + user.username + ' ' + t('使用趋势', 'usage trend')"
@click="selectUser(user)"
>
<ChartNoAxesCombined class="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
:title="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
:aria-label="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
@click="accountUser = user"
>
<ReceiptText class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
<TableRow v-if="!data?.data.items.length">
<TableCell
colspan="9"
class="py-12 text-center text-muted-foreground"
>
{{ loading ? t('加载中', 'Loading') : error ? t('用户账目暂不可用', 'User accounts unavailable') : t('没有符合条件的用户', 'No matching users') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<template #pagination>
<Pagination
v-if="data"
:total="data.data.total"
:current="Math.floor(requestQuery.offset / requestQuery.limit) + 1"
:page-size="requestQuery.limit"
:page-size-options="[25, 50, 100]"
@update:current="changePage"
@update:page-size="changePageSize"
/>
</template>
</TableCard>
</template>
</TableCard>
<UserAccountDrawer
v-if="accountUser"
:key="accountUser.user_id"
:user="accountUser"
:timezone="range.timezone"
@close="accountUser = null"
/>
</TabsContent>
<TabsContent value="usage">
<UserUsageStats v-if="activeTab === 'usage'" />
</TabsContent>
</Tabs>
</UserUsageStats>
</template>
</UserReports>
<UserAccountDrawer
v-if="accountUser"
:key="accountUser.user_id"
:user="accountUser"
:timezone="range.timezone"
@close="accountUser = null"
/>
</main>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, ref, watch } from 'vue'
import { Download, Search, ReceiptText } from 'lucide-vue-next'
import { Button, Input, Pagination, SortableTableHead, Table, TableBody, TableCard, TableCell, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { computed, ref, watch } from 'vue'
import { Download, Search, ReceiptText, ChartNoAxesCombined } from 'lucide-vue-next'
import { Button, Input, Pagination, SortableTableHead, Table, TableBody, TableCard, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
import TimeRangePicker from '@/components/common/TimeRangePicker.vue'
import type { DateRangeParams } from '@/features/usage/types'
import { overviewApi } from '@/api/overview'
import OverviewToolbar from '@/features/overview/components/OverviewToolbar.vue'
import OverviewStatus from '@/features/overview/components/OverviewStatus.vue'
import UserFinanceSummary from '@/features/overview/users/UserFinanceSummary.vue'
import UserReports from '@/features/overview/users/UserReports.vue'
import UserAccountDrawer from '@/features/overview/users/UserAccountDrawer.vue'
import UserUsageStats from '@/features/overview/users/UserUsageStats.vue'
import { useUserAnalysisRefresh } from '@/features/overview/users/useUserAnalysisRefresh'
import { useOverviewQuery } from '@/features/overview/query'
import { presetRange, rangeFromQuery, useOverviewQuery } from '@/features/overview/query'
import { useOverviewRequest } from '@/features/overview/useOverviewRequest'
import { useOverviewExport } from '@/features/overview/useOverviewExport'
import { useOverviewI18n } from '@/features/overview/i18n'
import { count, money, timestamp } from '@/features/overview/format'
const UserUsageStats = defineAsyncComponent(() => import('@/features/overview/users/UserUsageStats.vue'))
const activeTab = ref('accounts')
const { t } = useOverviewI18n()
const { query, range, relativePreset, refreshRange, setRange, patch } = useOverviewQuery('today', { rolling: true })
const { revision, autoRefresh, refreshTitle, refreshAll, toggleAutoRefresh } = useUserAnalysisRefresh(refreshRange)
@@ -249,6 +267,29 @@ const scope = computed(() => JSON.stringify({ ...requestQuery.value, ...(relativ
const { data, loading, error } = useOverviewRequest(() => JSON.stringify([requestQuery.value, revision.value]), signal => overviewApi.users(requestQuery.value, signal), { scopeKey: scope })
const { exporting, exportError, exportCsv } = useOverviewExport()
const columns = computed(() => [{ key: 'billable_amount', label: t('消费', 'Consumption') }, { key: 'request_count', label: t('请求数', 'Requests') }, { key: 'total_tokens', label: 'Tokens' }])
function sameRange(left: DateRangeParams, right: typeof range.value): boolean {
return left.from === right.from && left.to === right.to && (left.timezone || right.timezone) === right.timezone
}
function handleRangePicker(value: DateRangeParams) {
const timezone = value.timezone || range.value.timezone
if (value.preset) {
if (value.preset === relativePreset.value) return
const next = value.preset === 'yesterday'
? rangeFromQuery({ preset: value.preset, timezone }, range.value)
: presetRange(value.preset, timezone)
void setRange(next, value.preset)
return
}
if (value.from && value.to) {
if (sameRange(value, range.value)) return
void setRange({ from: value.from, to: value.to, timezone }, undefined)
return
}
if (value.start_date && value.end_date) {
const next = rangeFromQuery({ start_date: value.start_date, end_date: value.end_date, timezone }, range.value)
if (!sameRange(next, range.value)) void setRange(next)
}
}
function sort({ key, direction }: { key: string; direction: 'asc' | 'desc' }) {
void patch({ sort: key, order: direction, offset: undefined })
}
+1 -17
View File
@@ -46,15 +46,6 @@
/>
</div>
<!-- 分析统计 -->
<div
v-if="isAdminPage"
class="flex flex-wrap items-baseline gap-2 text-xs text-muted-foreground"
>
<h2 class="text-sm font-medium text-foreground">
{{ overviewT('全站时段统计', 'Site-wide period statistics') }}
</h2><span>{{ analyticsRangeLabel }}</span>
</div>
<!-- 管理员:模型 + 提供商 + API格式(3列) -->
<div
v-if="isAdminPage"
@@ -229,7 +220,7 @@ import type { ActivityHeatmap } from '@/types/activity'
import { useToast } from '@/composables/useToast'
import { queryString, rangeFromQuery, presetRange } from '@/features/overview/query'
import { useOverviewI18n } from '@/features/overview/i18n'
import { attributionLabel, timestamp } from '@/features/overview/format'
import { attributionLabel } from '@/features/overview/format'
import { Button } from '@/components/ui'
const route = useRoute()
@@ -314,13 +305,6 @@ const hasScopedRecordFilters = computed(() => isAdminPage.value && (
filterSearch.value.trim() !== '' || [filterUser.value, filterModel.value, filterProvider.value, filterApiFormat.value, filterStatus.value, filterClientFamily.value].some(value => value !== '__all__') ||
!!filterProviderId.value || !!filterApiKeyId.value || !!filterRequestId.value || !!filterAttribution.value || !!filterEndpointKind.value || !!filterRequestType.value || filterIsStream.value !== undefined || filterFormatConversion.value !== undefined || filterSlowThreshold.value !== undefined || hideUnknownRecords.value
))
const analyticsRangeLabel = computed(() => {
const range = timeRange.value
if (range.from && range.to) return `${timestamp(range.from, range.timezone)} - ${timestamp(range.to, range.timezone)} · ${range.timezone || 'UTC'}`
if (range.start_date && range.end_date) return `${range.start_date} - ${range.end_date} · ${range.timezone || 'UTC'}`
const presets: Record<string, string> = { today: overviewT('今天', 'Today'), yesterday: overviewT('昨天', 'Yesterday'), last7days: overviewT('最近 7 天', 'Last 7 days'), last30days: overviewT('最近 30 天', 'Last 30 days'), last90days: overviewT('最近 90 天', 'Last 90 days') }
return `${presets[range.preset || ''] || overviewT('全部时间', 'All time')} · ${range.timezone || 'UTC'}`
})
function clearRecordFilters() {
const fields = ['search', 'user_id', 'model', 'provider', 'api_format', 'status', 'client_family', 'provider_id', 'api_key_id', 'request_id', 'attribution_kind', 'endpoint_kind', 'request_type', 'is_stream', 'has_format_conversion', 'slow_threshold_ms', 'detail_id', 'page']
hideUnknownRecords.value = false