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:
@@ -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)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user