mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user