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
@@ -113,4 +113,13 @@ describe('adminApi analytics cache options', () => {
})
})
it('refreshes both leaderboards for the same precise period as user accounts', async () => {
const range = { from: '2026-09-10T15:20:42Z', to: '2026-09-10T16:20:42Z', timezone: 'Asia/Shanghai' }
await adminApi.getLeaderboardUsers(range, { skipCache: true })
await adminApi.getLeaderboardUserGroups(range, { skipCache: true })
expect(getMock).toHaveBeenCalledWith('/api/admin/stats/leaderboard/users', { params: range })
expect(getMock).toHaveBeenCalledWith('/api/admin/stats/leaderboard/user-groups', { params: range })
for (const call of cachedRequestMock.mock.calls as unknown[][]) expect(call[2]).toBe(0)
})
})
+10 -4
View File
@@ -1288,6 +1288,8 @@ export const adminApi = {
// Stats / Leaderboards
async getLeaderboardUsers(params?: {
from?: string
to?: string
start_date?: string
end_date?: string
preset?: string
@@ -1302,7 +1304,7 @@ export const adminApi = {
include_inactive?: boolean
exclude_admin?: boolean
user_group_id?: string
}): Promise<LeaderboardResponse> {
}, options?: AdminAnalyticsRequestOptions): Promise<LeaderboardResponse> {
const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params)
return cachedRequest(
cacheKey,
@@ -1312,11 +1314,13 @@ export const adminApi = {
})
return response.data
},
20 * 1000
options?.skipCache ? 0 : 20 * 1000
)
},
async getLeaderboardUserGroups(params?: {
from?: string
to?: string
start_date?: string
end_date?: string
preset?: string
@@ -1330,7 +1334,7 @@ export const adminApi = {
model?: string
include_inactive?: boolean
exclude_admin?: boolean
}): Promise<LeaderboardResponse> {
}, options?: AdminAnalyticsRequestOptions): Promise<LeaderboardResponse> {
const cacheKey = buildCacheKey('admin:stats:leaderboard:user-groups', params)
return cachedRequest(
cacheKey,
@@ -1341,7 +1345,7 @@ export const adminApi = {
)
return response.data
},
20 * 1000
options?.skipCache ? 0 : 20 * 1000
)
},
@@ -1621,6 +1625,8 @@ export const adminApi = {
async getTimeSeries(
params?: {
from?: string
to?: string
start_date?: string
end_date?: string
preset?: string
@@ -1,15 +1,14 @@
import { createApp, nextTick } from 'vue'
import { createApp, h, nextTick, reactive } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setI18nLocale } from '@/i18n'
import UserUsageStats from '../users/UserUsageStats.vue'
const api = vi.hoisted(() => ({
users: vi.fn(), groups: vi.fn(), members: vi.fn(),
userLeaderboard: vi.fn(), groupLeaderboard: vi.fn(), summary: vi.fn(), series: vi.fn(),
users: vi.fn(), groups: vi.fn(),
userLeaderboard: vi.fn(), groupLeaderboard: vi.fn(), series: vi.fn(),
}))
vi.mock('@/api/users', () => ({ usersApi: { getAllUsers: api.users, listUserGroups: api.groups, listUserGroupMembers: api.members } }))
vi.mock('@/api/users', () => ({ usersApi: { getAllUsers: api.users, listUserGroups: api.groups } }))
vi.mock('@/api/admin', () => ({ adminApi: { getLeaderboardUsers: api.userLeaderboard, getLeaderboardUserGroups: api.groupLeaderboard, getTimeSeries: api.series } }))
vi.mock('@/api/usage', () => ({ usageApi: { getUsageStats: api.summary } }))
vi.mock('@/components/charts/LineChart.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/common', () => ({ EmptyState: { render: () => null }, LoadingState: { render: () => null }, TimeRangePicker: { render: () => null } }))
vi.mock('@/components/ui', async importOriginal => {
@@ -31,13 +30,22 @@ vi.mock('@/components/ui', async importOriginal => {
})
let unmount = () => {}
const range = { from: '2026-09-10T15:20:42.000Z', to: '2026-09-10T16:20:42.000Z', timezone: 'Asia/Shanghai' }
let props = reactive({ range: { ...range }, revision: 0 })
async function settle() {
for (let index = 0; index < 10; index += 1) await Promise.resolve()
await nextTick()
}
async function mount() {
const root = document.createElement('div')
const app = createApp(UserUsageStats)
const app = createApp({
render: () => h(UserUsageStats, props, {
'user-leaderboard': ({ selectUser }: { selectUser: (user: { user_id: string; username: string }) => void }) => h('button', {
'data-select-account': '',
onClick: () => selectUser({ user_id: 'user-3', username: 'Charlie' }),
}, 'Merged user accounts'),
}),
})
app.mount(root)
unmount = () => app.unmount()
await settle()
@@ -55,39 +63,113 @@ beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
setI18nLocale('en-US')
props = reactive({ range: { ...range }, revision: 0 })
api.users.mockResolvedValue([{ id: 'user-1', username: 'Alice', is_active: true, groups: [{ id: 'group-1' }] }, { id: 'user-2', username: 'Bob', is_active: true, groups: [] }])
api.groups.mockResolvedValue({ items: [{ id: 'group-1', name: 'Engineering' }, { id: 'group-2', name: 'Support' }] })
api.members.mockResolvedValue([{ id: 'user-1', is_active: true, is_deleted: false }])
api.userLeaderboard.mockResolvedValue({ items: [], total: 0 })
api.groupLeaderboard.mockResolvedValue({ items: [], total: 0 })
api.summary.mockResolvedValue({ total_requests: 5, total_tokens: 100, total_cost: 2 })
api.series.mockResolvedValue([])
})
afterEach(() => { unmount(); vi.useRealTimers(); setI18nLocale('zh-CN') })
describe('user and group usage statistics', () => {
it('applies group scope to summary, trends and member rankings, and can compare groups', async () => {
it('uses the merged account table without fetching another user leaderboard', async () => {
const root = await mount()
expect(root.textContent).toContain('Merged user accounts')
expect(api.userLeaderboard).not.toHaveBeenCalled()
expect(api.groupLeaderboard).not.toHaveBeenCalled()
expect(root.textContent).not.toContain('User summary')
expect(root.textContent).not.toContain('User-group summary')
root.querySelector<HTMLButtonElement>('[data-select-account]')!.click()
await nextTick()
await vi.advanceTimersByTimeAsync(120)
await settle()
expect(root.querySelectorAll('select')[1].value).toBe('user-3')
expect(root.querySelector('h3')?.parentElement?.textContent).toContain('Charlie')
expect(api.series).toHaveBeenLastCalledWith(expect.objectContaining({ ...range, user_id: 'user-3' }), { skipCache: true })
await select(root, 0, 'user_group')
expect(root.querySelector('[data-select-account]')).toBeNull()
await select(root, 0, 'user')
expect(root.querySelectorAll('select')[1].value).toBe('user-3')
expect(root.querySelector('h3')?.parentElement?.textContent).toContain('Charlie')
expect(api.userLeaderboard.mock.calls.every(([params]) => params.user_group_id)).toBe(true)
})
it('applies group scope to trends and member rankings, and can compare groups', async () => {
const root = await mount()
expect(api.summary).toHaveBeenLastCalledWith(expect.objectContaining({ user_id: 'user-1' }))
await select(root, 0, 'user_group')
expect(api.groupLeaderboard).toHaveBeenCalled()
expect(api.summary).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: 'group-1' }))
expect(api.summary.mock.lastCall?.[0]).not.toHaveProperty('user_id')
expect(api.userLeaderboard).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: 'group-1', limit: 10 }))
expect(api.members).toHaveBeenLastCalledWith('group-1')
expect(api.userLeaderboard).toHaveBeenLastCalledWith(expect.objectContaining({ ...range, user_group_id: 'group-1', limit: 10 }), { skipCache: true })
await select(root, 2, 'group-2')
expect(api.series).toHaveBeenCalledWith(expect.objectContaining({ user_group_id: 'group-2' }))
expect(api.series).toHaveBeenCalledWith(expect.objectContaining({ ...range, user_group_id: 'group-2' }), { skipCache: true })
expect(root.textContent).toContain('Group member leaderboard')
})
it('keeps the ungrouped sentinel across scoped queries without fetching a fictitious group', async () => {
const root = await mount()
await select(root, 0, 'user_group')
api.members.mockClear()
await select(root, 1, '__ungrouped__')
expect(api.summary).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: '__ungrouped__' }))
expect(api.series).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: '__ungrouped__' }))
expect(api.userLeaderboard).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: '__ungrouped__' }))
expect(api.members).not.toHaveBeenCalled()
expect(api.series).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: '__ungrouped__' }), { skipCache: true })
expect(api.userLeaderboard).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: '__ungrouped__' }), { skipCache: true })
})
it('uses the page range and refresh for trends and comparisons without losing selection', async () => {
const root = await mount()
await select(root, 2, 'user-2')
api.userLeaderboard.mockClear()
api.series.mockClear()
props.range = { from: '2026-09-03T02:47:00.000Z', to: '2026-10-03T02:47:00.000Z', timezone: 'Asia/Shanghai' }
await nextTick()
await vi.advanceTimersByTimeAsync(120)
expect(api.userLeaderboard).not.toHaveBeenCalled()
expect(api.series).toHaveBeenCalledWith(expect.objectContaining({ ...props.range, user_id: 'user-2' }), { skipCache: true })
props.revision += 1
await nextTick()
await vi.advanceTimersByTimeAsync(120)
expect(api.userLeaderboard).not.toHaveBeenCalled()
expect(api.series).toHaveBeenCalledTimes(4)
expect(root.querySelector('h1')).toBeNull()
expect(root.querySelectorAll('select')[2].value).toBe('user-2')
})
it('discards the old trend when the page range changes during a request', async () => {
let resolveOld: (value: unknown) => void = () => {}
api.series.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
const root = await mount()
props.range = { ...range, from: '2026-09-10T16:00:00.000Z' }
await nextTick()
resolveOld([{ date: 'old', total_cost: 999 }])
await settle()
expect(root.textContent).not.toContain('999')
await vi.advanceTimersByTimeAsync(120)
expect(api.series.mock.lastCall?.[0]).toMatchObject(props.range)
})
it('reports failed requests and retries them without unmounting the usage section', async () => {
api.series.mockRejectedValueOnce(new Error('Trend unavailable'))
const root = await mount()
expect(root.textContent).toContain('Trend unavailable')
const retry = [...root.querySelectorAll('button')].find(item => item.textContent?.includes('Retry'))
expect(retry).toBeDefined()
retry!.click()
await settle()
expect(api.series).toHaveBeenCalledTimes(2)
expect(api.userLeaderboard).not.toHaveBeenCalled()
expect(root.textContent).not.toContain('Trend unavailable')
})
it('retries failed group rankings while preserving scoped member rankings', async () => {
api.groupLeaderboard.mockRejectedValueOnce(new Error('Group rankings unavailable'))
const root = await mount()
await select(root, 0, 'user_group')
expect(root.textContent).toContain('Group rankings unavailable')
const retry = [...root.querySelectorAll('button')].find(item => item.textContent?.includes('Retry'))
retry!.click()
await settle()
expect(api.groupLeaderboard).toHaveBeenCalledTimes(2)
expect(api.userLeaderboard).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: 'group-1' }), { skipCache: true })
expect(root.textContent).not.toContain('Group rankings unavailable')
})
})
@@ -1,4 +1,4 @@
import { createApp, h, nextTick, type Component } from 'vue'
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'
@@ -7,12 +7,18 @@ 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() }))
const usageStats = vi.hoisted(() => ({ selectUser: 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 } }))
vi.mock('@/features/overview/users/UserUsageStats.vue', () => ({ __esModule: true, default: { render: () => h('div', { 'data-user-usage-stats': '' }, 'Usage statistics') } }))
vi.mock('@/features/overview/users/UserUsageStats.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({
setup: (_, { slots }) => () => h('div', { 'data-user-usage-stats': '' }, slots['user-leaderboard']?.({ selectUser: usageStats.selectUser })),
}) }
})
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' },
@@ -64,17 +70,15 @@ beforeEach(() => {
afterEach(() => { cleanup.splice(0).forEach(fn => fn()); vi.useRealTimers(); setI18nLocale('zh-CN') })
describe('enterprise user accounts', () => {
it('loads usage and group statistics only when selected, preserving the account view', async () => {
it('shows account and usage statistics together on one page', async () => {
const { root } = await mount(UserStats, `/admin/user-stats?${range}`)
expect(root.querySelector('[data-user-usage-stats]')).toBeNull()
button(root, '使用统计').click()
await settle()
await settle()
expect(root.querySelector('[data-user-usage-stats]')).not.toBeNull()
root.querySelector<HTMLButtonElement>('button[data-value="accounts"]')?.click()
await settle()
expect(root.querySelector('[data-user-usage-stats]')).toBeNull()
expect(section(root, '[data-user-accounts]').textContent).toContain(employee.username)
expect(section(root, '[data-user-reports]').querySelector('[data-user-accounts]')).not.toBeNull()
expect(root.querySelectorAll('[data-user-accounts]')).toHaveLength(1)
expect(section(root, '[data-user-accounts]').textContent).toContain('用户排行与账目')
expect(root.querySelector('[role="tablist"]')).toBeNull()
expect(root.querySelector('button[data-value="accounts"]')).toBeNull()
expect(api.users).toHaveBeenCalledTimes(1)
})
@@ -84,25 +88,39 @@ describe('enterprise user accounts', () => {
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('button[data-value="accounts"][data-state="active"]')).not.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')
expect(section(root, '[data-user-rank]').textContent?.trim()).toBe('1')
button(root, '第 2 页').click()
await settle()
expect(api.users.mock.lastCall?.[0]).toMatchObject({ offset: 25 })
expect(section(root, '[data-user-rank]').textContent?.trim()).toBe('26')
button(root, 'Tokens').click()
await settle()
expect(api.users.mock.lastCall?.[0]).toMatchObject({ offset: 0, sort: 'total_tokens', order: 'desc' })
expect(section(root, '[data-user-rank]').textContent?.trim()).toBe('1')
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 () => {
it('opens trends from the merged table and labels ascending rows as positions', async () => {
const { root } = await mount(UserStats, `/admin/user-stats?${range}`)
const accounts = section(root, '[data-user-accounts]')
button(accounts, '使用趋势').click()
expect(usageStats.selectUser).toHaveBeenCalledWith(expect.objectContaining({ user_id: employee.user_id, username: employee.username }))
expect(accountApi.wallets).not.toHaveBeenCalled()
button(accounts, '消费').click()
await settle()
expect(api.users.mock.lastCall?.[0]).toMatchObject({ sort: 'billable_amount', order: 'asc', offset: 0 })
expect(accounts.querySelector('thead')?.textContent).toContain('序号')
expect(accounts.querySelector('thead')?.textContent).not.toContain('排名')
})
it('includes accounts in reports 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(reports.contains(accounts)).toBe(true)
expect(accountApi.wallets).not.toHaveBeenCalled()
expect(accountApi.plans).not.toHaveBeenCalled()
const before = router.currentRoute.value.fullPath
@@ -8,52 +8,54 @@
</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>
<slot name="range-picker">
<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>
</slot>
<RefreshButton
:loading="loading"
:active="refreshActive"
@@ -12,7 +12,7 @@
v-if="!userId"
class="mt-1 text-xs text-muted-foreground"
>
{{ t('全站用量 · 包含独立余额 Key · 不受用户搜索影响', 'Installation usage · Includes standalone balance keys · Independent of user search') }}
{{ t('模型用量与消费趋势为全站用量 · 包含独立余额 Key · 不受用户搜索影响', 'Model usage and consumption trends cover installation usage · Includes standalone balance keys · Independent of user search') }}
</p>
</div>
<Button
@@ -122,6 +122,7 @@
</div>
</section>
</div>
<slot name="additional" />
</section>
</template>
@@ -5,9 +5,9 @@
>
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<h1 class="text-lg font-semibold">
<h2 class="text-sm font-semibold">
{{ t('userStats.title') }}
</h1>
</h2>
<p class="text-xs text-muted-foreground">
{{ t('userStats.description') }}
</p>
@@ -63,20 +63,54 @@
</SelectItem>
</SelectContent>
</Select>
<TimeRangePicker
v-model="timeRange"
:allow-hourly="true"
/>
<Select v-model="granularity">
<SelectTrigger
class="h-8 w-24 text-xs"
:aria-label="legacyT('粒度')"
>
<SelectValue :placeholder="legacyT('粒度')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-if="canUseHourly"
value="hour"
>
{{ legacyT('小时') }}
</SelectItem>
<SelectItem value="day">
{{ legacyT('天') }}
</SelectItem>
<SelectItem value="week">
{{ legacyT('周') }}
</SelectItem>
<SelectItem value="month">
{{ legacyT('月') }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<OverviewStatus
:error="entitiesError || leaderboardError || panelsError"
@retry="reload"
/>
<slot
v-if="scope === 'user'"
name="user-leaderboard"
:select-user="selectUser"
/>
<div
v-else
class="grid grid-cols-1 gap-4"
>
<LeaderboardTable
:title="scope === 'user' ? t('userStats.leaderboard.user') : t('userStats.leaderboard.userGroup')"
:title="t('userStats.leaderboard.userGroup')"
:items="leaderboard"
:metric="metric"
:loading="leaderboardLoading"
:show-member-count="scope === 'user_group'"
show-member-count
selectable
@update:metric="metric = $event"
@select="selectLeaderboardItem"
@@ -105,78 +139,6 @@
</div>
</template>
</LeaderboardTable>
<Card class="space-y-3 p-4">
<div>
<h3 class="text-sm font-semibold">
{{ scope === 'user' ? t('userStats.summary.user') : t('userStats.summary.userGroup') }}
</h3>
<p class="mt-0.5 truncate text-xs text-muted-foreground">
{{ selectedEntityName || t('userStats.selectPrompt') }}
</p>
</div>
<div
v-if="summaryLoading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else
class="grid grid-cols-2 gap-3 text-sm"
>
<div>
<div class="text-xs text-muted-foreground">
{{ t('stats.metric.requests') }}
</div>
<div class="font-semibold">
{{ usageSummary?.total_requests ?? 0 }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
{{ t('stats.metric.tokens') }}
</div>
<div class="font-semibold">
{{ formatTokens(usageSummary?.total_tokens ?? 0) }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
{{ t('stats.metric.cost') }}
</div>
<div class="font-semibold">
{{ formatCurrency(usageSummary?.total_cost ?? 0) }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
{{ t('stats.metric.errorRate') }}
</div>
<div class="font-semibold">
{{ usageSummary?.error_rate ?? 0 }}%
</div>
</div>
<template v-if="scope === 'user_group'">
<div>
<div class="text-xs text-muted-foreground">
{{ t('userStats.members.current') }}
</div>
<div class="font-semibold">
{{ groupMemberCount }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
{{ t('userStats.members.active') }}
</div>
<div class="font-semibold">
{{ activeGroupMemberCount }}
</div>
</div>
</template>
</div>
</Card>
</div>
<LeaderboardTable
@@ -192,7 +154,10 @@
<Card class="space-y-4 p-4">
<div>
<h3 class="text-sm font-semibold">
<h3
ref="trendHeading"
class="text-sm font-semibold scroll-mt-4"
>
{{ scope === 'user' ? t('userStats.trend.user') : t('userStats.trend.userGroup') }}
</h3>
<p class="mt-0.5 truncate text-xs text-muted-foreground">
@@ -228,7 +193,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
Button,
Card,
@@ -239,40 +204,39 @@ import {
SelectValue
} from '@/components/ui'
import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState, TimeRangePicker } from '@/components/common'
import { LoadingState } from '@/components/common'
import { LeaderboardTable } from '@/components/stats'
import { adminApi, type LeaderboardItem } from '@/api/admin'
import { usersApi, type User, type UserGroup, type UserGroupMember } from '@/api/users'
import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format'
import { usersApi, type User, type UserGroup } from '@/api/users'
import { useI18n } from '@/i18n'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
import type { OverviewRange } from '@/api/overview'
import OverviewStatus from '../components/OverviewStatus.vue'
type StatsScope = 'user' | 'user_group'
type SelectableEntity = { id: string; name: string }
interface UsageSummary {
total_requests: number
total_tokens: number
total_cost: number
error_rate: number
}
type SelectedUser = { user_id: string; username: string }
interface TimeSeriesItem {
date: string
total_cost: number
}
const { t } = useI18n()
const props = defineProps<{ range: OverviewRange; revision: number }>()
defineSlots<{
'user-leaderboard'(props: { selectUser: (user: SelectedUser) => void }): unknown
}>()
const { t, legacyT } = useI18n()
const PAGE_SIZE = 10
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
const granularity = ref<'hour' | 'day' | 'week' | 'month'>('day')
const canUseHourly = computed(() => Date.parse(props.range.to) - Date.parse(props.range.from) <= 48 * 3_600_000)
const metric = ref<'requests' | 'tokens' | 'cost'>('requests')
const scope = ref<StatsScope>('user')
const users = ref<User[]>([])
const selectedUserNames = ref(new Map<string, string>())
const userGroups = ref<UserGroup[]>([])
const trendHeading = ref<HTMLElement | null>(null)
const selectedUserId = ref('')
const selectedUserGroupId = ref('')
const compareUserId = ref('__none__')
@@ -284,24 +248,31 @@ const leaderboardOffset = ref(0)
const leaderboardLoading = ref(false)
const memberLeaderboard = ref<LeaderboardItem[]>([])
const memberLeaderboardLoading = ref(false)
const groupMemberCount = ref(0)
const activeGroupMemberCount = ref(0)
const usageSummary = ref<UsageSummary | null>(null)
const summaryLoading = ref(false)
const series = ref<TimeSeriesItem[]>([])
const comparisonSeries = ref<TimeSeriesItem[]>([])
const seriesLoading = ref(false)
const entitiesError = ref<string | null>(null)
const leaderboardError = ref<string | null>(null)
const panelsError = ref<string | null>(null)
let leaderboardRequestId = 0
let panelRequestId = 0
let leaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
let panelDebounceTimer: ReturnType<typeof setTimeout> | null = null
let ready = false
let disposed = false
const allEntities = computed<SelectableEntity[]>(() => scope.value === 'user'
? users.value.map(user => ({ id: user.id, name: user.username || user.email || user.id }))
: [...userGroups.value.map(group => ({ id: group.id, name: group.name })),
{ id: '__ungrouped__', name: t('userStats.ungrouped') }])
const allEntities = computed<SelectableEntity[]>(() => {
if (scope.value === 'user_group') {
return [...userGroups.value.map(group => ({ id: group.id, name: group.name })),
{ id: '__ungrouped__', name: t('userStats.ungrouped') }]
}
const entities = new Map(users.value.map(user => [user.id, {
id: user.id, name: user.username || user.email || user.id,
}]))
selectedUserNames.value.forEach((name, id) => entities.set(id, { id, name }))
return [...entities.values()]
})
const selectedEntityId = computed({
get: () => scope.value === 'user' ? selectedUserId.value : selectedUserGroupId.value,
@@ -335,12 +306,8 @@ const hasNextLeaderboardPage = computed(
function buildTimeRangeParams() {
return {
start_date: timeRange.value.start_date,
end_date: timeRange.value.end_date,
preset: timeRange.value.preset,
timezone: timeRange.value.timezone,
tz_offset_minutes: timeRange.value.tz_offset_minutes,
granularity: timeRange.value.granularity || 'day'
...props.range,
granularity: canUseHourly.value || granularity.value !== 'hour' ? granularity.value : 'day' as const,
}
}
@@ -359,18 +326,32 @@ function ensureSelectedEntity() {
}
async function loadEntities() {
const [loadedUsers, groupsResponse] = await Promise.all([
usersApi.getAllUsers(),
usersApi.listUserGroups()
])
users.value = loadedUsers
userGroups.value = groupsResponse.items
ensureSelectedEntity()
entitiesError.value = null
try {
const [loadedUsers, groupsResponse] = await Promise.all([
usersApi.getAllUsers(),
usersApi.listUserGroups()
])
if (disposed) return
users.value = loadedUsers
userGroups.value = groupsResponse.items
ensureSelectedEntity()
} catch (error) {
if (!disposed) entitiesError.value = error instanceof Error ? error.message : String(error)
}
}
async function loadLeaderboard() {
const requestId = ++leaderboardRequestId
if (scope.value !== 'user_group') {
leaderboard.value = []
leaderboardTotal.value = 0
leaderboardLoading.value = false
leaderboardError.value = null
return
}
leaderboardLoading.value = true
leaderboardError.value = null
try {
const params = {
...buildTimeRangeParams(),
@@ -378,12 +359,12 @@ async function loadLeaderboard() {
limit: PAGE_SIZE,
offset: leaderboardOffset.value
}
const response = scope.value === 'user'
? await adminApi.getLeaderboardUsers(params)
: await adminApi.getLeaderboardUserGroups(params)
const response = await adminApi.getLeaderboardUserGroups(params, { skipCache: true })
if (requestId !== leaderboardRequestId) return
leaderboard.value = response.items
leaderboardTotal.value = response.total
} catch (error) {
if (requestId === leaderboardRequestId) leaderboardError.value = error instanceof Error ? error.message : String(error)
} finally {
if (requestId === leaderboardRequestId) leaderboardLoading.value = false
}
@@ -392,16 +373,15 @@ async function loadLeaderboard() {
async function loadPanels() {
const selectedId = selectedEntityId.value
const requestId = ++panelRequestId
panelsError.value = null
if (!selectedId) {
usageSummary.value = null
series.value = []
comparisonSeries.value = []
memberLeaderboard.value = []
groupMemberCount.value = 0
activeGroupMemberCount.value = 0
seriesLoading.value = false
memberLeaderboardLoading.value = false
return
}
summaryLoading.value = true
seriesLoading.value = true
memberLeaderboardLoading.value = scope.value === 'user_group'
try {
@@ -411,7 +391,7 @@ async function loadPanels() {
? adminApi.getTimeSeries({
...buildTimeRangeParams(),
...scopeParams(compareEntityId.value)
})
}, { skipCache: true })
: Promise.resolve([])
const memberPromise: Promise<{ items: LeaderboardItem[] }> = scope.value === 'user_group'
? adminApi.getLeaderboardUsers({
@@ -419,33 +399,21 @@ async function loadPanels() {
metric: metric.value,
user_group_id: selectedId,
limit: PAGE_SIZE
})
}, { skipCache: true })
: Promise.resolve({ items: [] })
const groupMembersPromise: Promise<UserGroupMember[]> = scope.value === 'user_group' && selectedId !== '__ungrouped__'
? usersApi.listUserGroupMembers(selectedId)
: Promise.resolve([])
const [summary, primarySeries, compareSeries, members, groupMembers] = await Promise.all([
usageApi.getUsageStats(primaryParams),
adminApi.getTimeSeries(primaryParams),
const [primarySeries, compareSeries, members] = await Promise.all([
adminApi.getTimeSeries(primaryParams, { skipCache: true }),
comparisonPromise,
memberPromise,
groupMembersPromise
])
if (requestId !== panelRequestId) return
usageSummary.value = { ...summary, error_rate: summary.error_rate ?? 0 }
series.value = primarySeries
comparisonSeries.value = compareSeries
memberLeaderboard.value = members.items
const ungroupedUsers = users.value.filter(user => user.groups?.length === 0)
groupMemberCount.value = selectedId === '__ungrouped__'
? ungroupedUsers.length : groupMembers.filter(member => !member.is_deleted).length
activeGroupMemberCount.value = selectedId === '__ungrouped__'
? ungroupedUsers.filter(user => user.is_active).length
: groupMembers.filter(member => !member.is_deleted && member.is_active).length
} catch (error) {
if (requestId === panelRequestId) panelsError.value = error instanceof Error ? error.message : String(error)
} finally {
if (requestId === panelRequestId) {
summaryLoading.value = false
seriesLoading.value = false
memberLeaderboardLoading.value = false
}
@@ -457,8 +425,14 @@ function selectLeaderboardItem(item: LeaderboardItem) {
}
function selectMember(item: LeaderboardItem) {
selectUser({ user_id: item.id, username: item.name })
}
function selectUser(user: SelectedUser) {
selectedUserNames.value.set(user.user_id, user.username || user.user_id)
scope.value = 'user'
selectedUserId.value = item.id
selectedUserId.value = user.user_id
void nextTick(() => trendHeading.value?.scrollIntoView?.({ behavior: 'smooth', block: 'start' }))
}
function changeLeaderboardPage(direction: -1 | 1) {
@@ -499,6 +473,7 @@ const comparisonChartData = computed(() => ({
function scheduleLeaderboardLoad() {
if (!ready) return
leaderboardRequestId += 1
if (leaderboardDebounceTimer) clearTimeout(leaderboardDebounceTimer)
leaderboardDebounceTimer = setTimeout(() => {
leaderboardDebounceTimer = null
@@ -508,6 +483,7 @@ function scheduleLeaderboardLoad() {
function schedulePanelLoad() {
if (!ready) return
panelRequestId += 1
if (panelDebounceTimer) clearTimeout(panelDebounceTimer)
panelDebounceTimer = setTimeout(() => {
panelDebounceTimer = null
@@ -521,20 +497,24 @@ watch(scope, () => {
scheduleLeaderboardLoad()
schedulePanelLoad()
})
watch([timeRange, metric], () => {
leaderboardOffset.value = 0
watch([() => props.range, () => props.revision, granularity, metric], (next, previous) => {
if (next[1] === previous[1]) leaderboardOffset.value = 0
if (!canUseHourly.value && granularity.value === 'hour') granularity.value = 'day'
scheduleLeaderboardLoad()
schedulePanelLoad()
}, { deep: true })
watch([selectedEntityId, compareEntityId], schedulePanelLoad)
onMounted(async () => {
await loadEntities()
async function reload() {
if (!ready || entitiesError.value) await loadEntities()
if (disposed) return
ready = true
await Promise.all([loadLeaderboard(), loadPanels()])
})
}
onMounted(reload)
onUnmounted(() => {
disposed = true
if (leaderboardDebounceTimer) clearTimeout(leaderboardDebounceTimer)
if (panelDebounceTimer) clearTimeout(panelDebounceTimer)
leaderboardRequestId += 1
@@ -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